#!/usr/bin/env node // Local finishing for the generated clip with ffmpeg: a fade up from black (the reveal), an optional fade out, // fine moving film grain, and a web-ready H.264 encode. With --poster it also saves a still from after the fade // (the clip itself starts on black, so never use its first frame as a poster). No API, no cost. // Usage: // node scripts/finish.mjs --input raw.mp4 --output reveal.mp4 [--poster poster.jpg] [--poster-at 3] [--fade-in 1.2] [--fade-out 0.5] [--grain 5] [--trim-start 0] [--duration 8] [--keep-audio] import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' const usage = 'Usage: node scripts/finish.mjs --input raw.mp4 --output reveal.mp4 [--poster poster.jpg] [--poster-at 3] [--fade-in 1.2] [--fade-out 0.5] [--grain 5] [--trim-start 0] [--duration 8] [--keep-audio]' const VALUE_FLAGS = new Set(['input', 'output', 'fade-in', 'fade-out', 'grain', 'trim-start', 'duration', 'poster', 'poster-at']) const SWITCHES = new Set(['keep-audio']) const fail = (message) => { console.error(`${message}\n${usage}`); process.exit(1) } const options = {} const args = process.argv.slice(2) for (let i = 0; i < args.length; i += 1) { const arg = args[i] if (!arg.startsWith('--')) fail(`Unexpected argument "${arg}".`) const key = arg.slice(2) if (SWITCHES.has(key)) { options[key] = true; continue } if (!VALUE_FLAGS.has(key)) fail(`Unknown flag --${key}.`) const value = args[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) options[key] = value i += 1 } if (!options.input || !options.output) fail('--input and --output are required.') if (!/\.mp4$/i.test(options.output)) fail('--output must end in .mp4.') if (options.poster && !/\.(jpe?g|png|webp)$/i.test(options.poster)) fail('--poster must end in .jpg, .png or .webp.') if (!existsSync(options.input)) fail(`Not found: ${options.input}`) const num = (key, fallback, max) => { const value = Number(options[key] ?? fallback) if (!Number.isFinite(value) || value < 0 || value > max) fail(`--${key} must be a number from 0 to ${max}.`) return value } const probe = spawnSync('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', options.input], { encoding: 'utf8' }) if (probe.error) fail('ffprobe was not found. Install ffmpeg 5 or newer (it includes ffprobe).') if (probe.status !== 0) fail(`${options.input} is not a video ffmpeg can read. Pass the MP4 that Seedance returned.`) const info = JSON.parse(probe.stdout) const stream = (info.streams ?? []).find((s) => s.codec_type === 'video') const total = Number(info.format?.duration) if (!stream || ['mjpeg', 'png', 'webp', 'bmp', 'gif'].includes(stream.codec_name) || !(total >= 1)) { fail(`${options.input} is a still image or too short to be a clip. finish.mjs needs the generated video (.mp4); for the keyframe, use build-video-input.mjs.`) } const start = num('trim-start', 0, 60) const length = Math.min(num('duration', total - start, 60), total - start) if (!(length > 0)) fail('Nothing left after --trim-start.') const fadeIn = num('fade-in', 1.2, 5) const fadeOut = num('fade-out', 0.5, 5) const grain = Math.round(num('grain', 5, 30)) const filters = [] if (fadeIn > 0) filters.push(`fade=t=in:st=0:d=${fadeIn.toFixed(3)}`) if (fadeOut > 0) filters.push(`fade=t=out:st=${Math.max(0, length - fadeOut).toFixed(3)}:d=${fadeOut.toFixed(3)}`) filters.push('format=yuv420p') if (grain > 0) filters.push(`noise=c0s=${grain}:c0f=t+u`) const run = spawnSync('ffmpeg', [ '-y', '-v', 'error', '-ss', String(start), '-t', length.toFixed(3), '-i', options.input, '-vf', filters.join(','), '-c:v', 'libx264', '-profile:v', 'high', '-preset', 'slow', '-crf', '18', '-maxrate', '6M', '-bufsize', '12M', '-pix_fmt', 'yuv420p', ...(options['keep-audio'] ? ['-c:a', 'aac', '-b:a', '160k'] : ['-an']), '-movflags', '+faststart', options.output, ], { stdio: 'inherit' }) if (run.error) fail('ffmpeg was not found. Install ffmpeg 5 or newer.') if (run.status !== 0) fail('ffmpeg could not finish this clip; see its error above.') console.log(`${options.output} (${length.toFixed(2)} s${options['keep-audio'] ? '' : ', muted'})`) if (options.poster) { const at = Math.min(num('poster-at', 3, 60), Math.max(0, length - 0.1)) const still = spawnSync('ffmpeg', ['-y', '-v', 'error', '-ss', at.toFixed(3), '-i', options.output, '-frames:v', '1', '-q:v', '2', options.poster], { stdio: 'inherit' }) if (still.status !== 0) fail('ffmpeg could not save the poster frame.') console.log(`${options.poster} (still at ${at.toFixed(1)} s, after the fade)`) }