/** * 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 { randomUUID } from 'node:crypto'; import { statSync } from 'node:fs'; import { copyFile, mkdir, readFile, rename, rm } from 'node:fs/promises'; import { basename, dirname, 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). Returns the * path it actually wrote when that is NOT `outImagePath` — a route whose transport * already saved the provider's own last frame hands that file back instead of * re-encoding one. Returning nothing means the frame is at `outImagePath`. */ 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`); const written = await deps.extractLastFrame(localVideoPath, outImage); return deps.uploadImage(typeof written === 'string' && written ? written : outImage); } /** * The last frame a transport already downloaded beside a rendered clip * (`scene-3.mp4` -> `scene-3-last-frame.png`), or null when there is none worth * reusing. The provider's OWN final frame beats an ffmpeg grab of the encoded clip * and costs nothing, but only for the render sitting next to it: a re-render leaves * the previous png in place (the transport only downloads one when the file is * absent), so a frame OLDER than the video would seed the next scene from a render * that no longer exists. Empty, missing and stale frames all fall back to ffmpeg. */ export function savedLastFrameFor(videoPath: string): string | null { if (!videoPath) return null; const candidate = join(dirname(videoPath), `${basename(videoPath, extname(videoPath))}-last-frame.png`); try { const frame = statSync(candidate); if (!frame.isFile() || frame.size === 0) return null; return frame.mtimeMs >= statSync(videoPath).mtimeMs ? candidate : null; } catch { return null; // no sibling frame (or no video) — extract one } } /** Injected I/O for {@link freezeChainSeedFrame} — real impls in {@link localChainSeedFreezeDeps}. */ export interface ChainSeedFreezeDeps { /** The provider's own last frame saved beside the clip, or null ({@link savedLastFrameFor}). */ savedLastFrame: (videoPath: string) => string | null; /** Write the final frame of `videoPath` to `outImagePath`, a `.jpg`. */ extractLastFrame: (videoPath: string, outImagePath: string) => Promise; } /** Real deps for {@link freezeChainSeedFrame}: the saved provider frame, else ffmpeg. */ export function localChainSeedFreezeDeps(): ChainSeedFreezeDeps { return { savedLastFrame: savedLastFrameFor, extractLastFrame: ffmpegExtractLastFrame }; } /** * The previous clip's last frame, written ONCE as `/-lastframe.` * and read back by every later call. The durable queue resolves a chained task at * quote time, again at the pre-submit revalidation and again at submit, and the quote * binds the frame's bytes: re-extracting each time would let an ffmpeg upgrade (the * jpg carries the encoder version) or a provider png that appeared after the quote * move the binding, and would overwrite the quoted frame. `key` names the source (the * selected candidate id), so two takes whose clips share a file name never share a * frame. The frame is reused while it is at least as new as the clip, the rule * {@link savedLastFrameFor} uses, and also when the clip has since gone: it is still * that candidate's frame. It is written through a temporary file and a rename, so a * second worker never reads half a jpg. */ export async function freezeChainSeedFrame( videoPath: string, key: string, workDir: string, deps: ChainSeedFreezeDeps, ): Promise { const stem = `${key.replace(/[^A-Za-z0-9._-]/g, '_')}-lastframe`; let clipMtimeMs: number | null = null; try { clipMtimeMs = statSync(videoPath).mtimeMs; } catch { clipMtimeMs = null; } for (const ext of ['.png', '.jpg']) { const frozen = join(workDir, `${stem}${ext}`); try { const frame = statSync(frozen); if (frame.isFile() && frame.size > 0 && (clipMtimeMs === null || frame.mtimeMs >= clipMtimeMs)) return frozen; } catch { // not frozen under this extension yet } } await mkdir(workDir, { recursive: true }); const saved = deps.savedLastFrame(videoPath); const ext = saved ? '.png' : '.jpg'; const target = join(workDir, `${stem}${ext}`); const temporary = join(workDir, `${stem}.${process.pid}-${randomUUID()}.tmp${ext}`); try { if (saved) await copyFile(saved, temporary); else await deps.extractLastFrame(videoPath, temporary); await rename(temporary, target); } finally { await rm(temporary, { force: true }); } // A stale frame under the other extension would win once the clip is gone. await rm(join(workDir, `${stem}${ext === '.png' ? '.jpg' : '.png'}`), { force: true }); return target; } /** 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; } /** * Deps for a route that hosts its own references at submit time * (`reapi-seedance`): the frame the transport already saved beside the clip is * reused, else one is extracted with ffmpeg, and the LOCAL image path is * returned unchanged — so the cinema quote and the run-contract approval still * hash bytes on disk and the transport hosts the frame as the next scene's * `first_frame`, instead of paying reAPI to ingest the previous clip as a * billed video reference. `extractLastFrame` is injectable for tests. */ export function localChainSeedFrameDeps( extractLastFrame: ChainSeedHostDeps['extractLastFrame'] = ffmpegExtractLastFrame, ): ChainSeedHostDeps { return { // The reAPI transport downloads each completed scene's own last frame beside the // clip, so prefer that file over re-deriving one: it is the frame the provider // ended on, and skipping the ffmpeg pass costs a decode per chain link. extractLastFrame: async (videoPath, outImagePath) => savedLastFrameFor(videoPath) ?? extractLastFrame(videoPath, outImagePath), uploadImage: async (imagePath) => imagePath, }; } /** 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), }; }