// Go Bananas media hosting — uploads a local video/audio (or image) file to GB's // durable public R2 bucket and returns the permanent URL. This is the on-platform // replacement for ephemeral third-party hosts (uguu.se etc.) when a provider needs // a HOSTED reference URL — notably the Seedance r2v voice-lock, which requires the // black-frame voice video as a public `.mp4` URL. // // Contract (GB `POST /api/media/upload`): multipart `file`, `X-API-Key` auth, returns // `{ url, media_kind, format, size_bytes, duration_seconds?, width?, height? }`. The // returned `url` is permanent and preserves the original extension, so a `.mp4` in // yields a `.mp4` URL out (classifies correctly into `reference_videos`). // // Until GB PR #8 deploys the endpoint, `POST /api/media/upload` 404s; callers use // `tryUploadMediaToGoBananas` which degrades to `null` (the voice clip then stays a // local path and the operator is warned), so nothing breaks pre-deploy. import { readFile } from 'node:fs/promises'; import { basename, extname } from 'node:path'; const DEFAULT_BASE_URL = 'https://gobananasai.com'; const MIME_BY_EXT: Record = { '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', }; export interface MediaUploadResult { /** Durable public URL (extension preserved). */ url: string; mediaId?: number; mediaKind?: string; format?: string; mimeType?: string; sizeBytes?: number; /** Best-effort; returned by GB for mp4/mov/m4a/wav. */ durationSeconds?: number; width?: number; height?: number; } export interface MediaHostOptions { /** GO_BANANAS_API_KEY (defaults to the env var). */ apiKey?: string; /** Base URL (defaults to GO_BANANAS_API_URL or gobananasai.com). */ apiUrl?: string; /** Injected fetch (tests). */ fetchImpl?: typeof fetch; } export class MediaHostError extends Error { readonly code: string; constructor(code: string, message: string) { super(message); this.code = code; this.name = 'MediaHostError'; } } /** * Upload a local media file to Go Bananas and return its durable public URL. * Throws `MediaHostError` when the key is missing or the upload fails (e.g. the * endpoint is not deployed yet). Prefer `tryUploadMediaToGoBananas` for graceful, * fall-back-to-local behavior. */ export async function uploadMediaToGoBananas( filePath: string, options: MediaHostOptions = {}, ): Promise { const apiKey = options.apiKey ?? process.env.GO_BANANAS_API_KEY ?? ''; if (!apiKey) { throw new MediaHostError('media_host_no_api_key', 'GO_BANANAS_API_KEY is required to host media.'); } const apiUrl = (options.apiUrl ?? process.env.GO_BANANAS_API_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); const fetchImpl = options.fetchImpl ?? fetch; const bytes = await readFile(filePath); const fileName = basename(filePath); const mime = MIME_BY_EXT[extname(fileName).toLowerCase()] ?? 'application/octet-stream'; const form = new FormData(); // A Node Buffer is a valid BlobPart; the third arg is the multipart filename. form.set('file', new Blob([bytes], { type: mime }), fileName); const response = await fetchImpl(`${apiUrl}/api/media/upload`, { method: 'POST', headers: { 'X-API-Key': apiKey }, // do NOT set Content-Type — fetch adds the multipart boundary body: form, }); const text = await response.text(); if (!response.ok) { throw new MediaHostError( 'media_host_failed', `POST /api/media/upload failed: ${response.status} ${text.slice(0, 200)}`, ); } let payload: { url?: string; media_id?: number; media_kind?: string; format?: string; mime_type?: string; size_bytes?: number; duration_seconds?: number; width?: number; height?: number; }; try { payload = JSON.parse(text); } catch { throw new MediaHostError('media_host_bad_response', `media upload returned non-JSON: ${text.slice(0, 200)}`); } if (!payload.url) { throw new MediaHostError('media_host_no_url', `media upload returned no url: ${text.slice(0, 200)}`); } return { url: payload.url, ...(payload.media_id !== undefined ? { mediaId: payload.media_id } : {}), ...(payload.media_kind ? { mediaKind: payload.media_kind } : {}), ...(payload.format ? { format: payload.format } : {}), ...(payload.mime_type ? { mimeType: payload.mime_type } : {}), ...(payload.size_bytes !== undefined ? { sizeBytes: payload.size_bytes } : {}), ...(typeof payload.duration_seconds === 'number' ? { durationSeconds: payload.duration_seconds } : {}), ...(payload.width !== undefined ? { width: payload.width } : {}), ...(payload.height !== undefined ? { height: payload.height } : {}), }; } /** * Graceful variant: returns the upload result, or `null` if hosting is unavailable * (no key, endpoint not deployed, network error). The `onWarn` callback receives a * one-line reason so the caller can surface it without failing the operation. */ export async function tryUploadMediaToGoBananas( filePath: string, options: MediaHostOptions & { onWarn?: (message: string) => void } = {}, ): Promise { const { onWarn, ...rest } = options; try { return await uploadMediaToGoBananas(filePath, rest); } catch (err) { const message = err instanceof Error ? err.message : String(err); onWarn?.(`media hosting unavailable (${message}); kept local path.`); return null; } }