/** * Hosting the auto-chain seed for the seedance-direct route. * * Auto-chain feeds scene N's rendered video into scene N+1 as a continuity * reference. On runway/dreamina-useapi the transport uploads that local file * itself, but **seedance-direct rejects local paths** — a reference must be a * hosted HTTP(S) URL or an `Asset://` avatar URI. A whole-video host is also not * available with the project's current credentials, so this module takes the * proven seedance keyframe path instead: extract the prior scene's **last * frame** as a still image, upload it to Go Bananas (which returns a public R2 * URL), and hand that hosted image back as the chain seed. native-seedance then * routes a `.jpg` URL into `reference_images` (the image-to-video keyframe). * * Pure orchestration with injectable I/O deps (`extractLastFrame` + `uploadImage`) * so the wiring is exercised fully offline; `defaultChainSeedHostDeps` supplies * the real ffmpeg + Go Bananas implementations for production. */ import { mkdir, readFile } from 'node:fs/promises'; import { basename, extname, join } from 'node:path'; import { runFfmpeg } from './assemble/ffmpeg.js'; const HOSTED_RE = /^https?:\/\//i; const VIDEO_EXTS = new Set(['.mp4', '.mov', '.webm', '.avi', '.mkv']); const GO_BANANAS_DEFAULT_BASE_URL = 'https://gobananasai.com/api'; /** Injected I/O for {@link hostChainSeedAsImage} — real impls in {@link defaultChainSeedHostDeps}. */ export interface ChainSeedHostDeps { /** Extract the final frame of a local video into an image file (jpg). */ extractLastFrame: (videoPath: string, outImagePath: string) => Promise; /** Upload a local image file; resolve to its public hosted URL. */ uploadImage: (imagePath: string) => Promise; } /** * True when a chain-seed path is a LOCAL video that must be hosted before a * seedance-direct submit. Already-hosted URLs, `Asset://` avatar URIs, and * non-video paths are left alone (they submit as-is or are not chain seeds). */ export function chainSeedNeedsHosting(path: string): boolean { if (!path) return false; if (HOSTED_RE.test(path)) return false; // already a hosted URL if (path.startsWith('Asset://')) return false; // managed avatar URI const ext = (path.split('?')[0]?.match(/\.[^.\\/]+$/)?.[0] ?? '').toLowerCase(); return VIDEO_EXTS.has(ext); } /** * Transform a local-video chain seed into a hosted last-frame image URL. Returns * the path UNCHANGED when it doesn't need hosting (already a URL / `Asset://` / * not a local video), so the caller can apply it unconditionally. * * On the hosting path it writes the extracted frame under `workDir` and returns * the uploaded image's public URL. */ export async function hostChainSeedAsImage( localVideoPath: string, workDir: string, deps: ChainSeedHostDeps, ): Promise { if (!chainSeedNeedsHosting(localVideoPath)) return localVideoPath; await mkdir(workDir, { recursive: true }); const stem = basename(localVideoPath, extname(localVideoPath)); const outImage = join(workDir, `${stem}-lastframe.jpg`); await deps.extractLastFrame(localVideoPath, outImage); return deps.uploadImage(outImage); } /** Extract the final frame of `videoPath` to `outImagePath` (jpg) via ffmpeg. */ async function ffmpegExtractLastFrame(videoPath: string, outImagePath: string): Promise { // `-sseof -1` seeks to one second before EOF (an input option, so it precedes // `-i`); `-update 1 -frames:v 1` then writes the single final decoded frame. await runFfmpeg([ '-sseof', '-1', '-i', videoPath, '-update', '1', '-frames:v', '1', '-q:v', '2', outImagePath, ]); } /** Pull the public hosted URL out of a Go Bananas `/images/upload` response (shape-tolerant). */ function extractPublicUrl(json: Record): string | undefined { const data = json.data as Record | undefined; const candidates = [ json.public_url, json.publicUrl, json.full_url, json.fullUrl, json.url, json.image_url, json.imageUrl, data?.public_url, data?.url, ]; for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.length > 0) return candidate; } return undefined; } /** * Upload a local image to Go Bananas' multipart upload endpoint and return its * public R2 URL. Mirrors the proven flow in `outpaint-keyframe.ts`: * POST {apiBase}/images/upload (multipart/form-data, field `file`) * → { image_id, public_url, ... } */ async function goBananasUploadImage( imagePath: string, env: NodeJS.ProcessEnv, fetcher: typeof fetch, ): Promise { const apiKey = (env.GO_BANANAS_API_KEY ?? '').trim(); if (!apiKey) { throw new Error('GO_BANANAS_API_KEY is required to host the seedance chain seed (last-frame image).'); } const apiBase = (env.GO_BANANAS_API_URL ?? GO_BANANAS_DEFAULT_BASE_URL).trim(); const bytes = await readFile(imagePath); const form = new FormData(); // Do NOT set Content-Type — FormData sets the multipart boundary itself. form.append('file', new Blob([new Uint8Array(bytes)], { type: 'image/jpeg' }), basename(imagePath)); const res = await fetcher(`${apiBase}/images/upload`, { method: 'POST', headers: { 'X-API-Key': apiKey }, body: form, }); if (!res.ok) { const text = await res.text().catch(() => '(no body)'); throw new Error(`go-bananas chain-seed upload HTTP ${res.status}: ${text.slice(0, 200)}`); } const json = (await res.json().catch(() => ({}))) as Record; const url = extractPublicUrl(json); if (!url) { throw new Error( `go-bananas chain-seed upload returned no public URL: ${JSON.stringify(json).slice(0, 200)}`, ); } return url; } /** Production deps: real ffmpeg last-frame extraction + Go Bananas image hosting. */ export function defaultChainSeedHostDeps( env: NodeJS.ProcessEnv = process.env, fetcher: typeof fetch = fetch, ): ChainSeedHostDeps { return { extractLastFrame: ffmpegExtractLastFrame, uploadImage: (imagePath) => goBananasUploadImage(imagePath, env, fetcher), }; }