#!/usr/bin/env node // Cuts the approved shot clips into one graded film with ffmpeg: trims, cuts or dissolves, // a film grade preset, optional bloom, grain and vignette, optional letterbox or vertical crop, // a title card over a chosen shot and an end card, then H.264 MP4 with +faststart. // Text is rendered by scripts/text-card.mjs from a .ttf you pass, so it is spelled exactly as typed // and works with ffmpeg builds that have no drawtext filter. // // Usage: // node scripts/assemble-film.mjs --film film.json --out film.mp4 --title-font fonts/Serif-Italic.ttf // [--small-font fonts/Serif.ttf] [--grade teal-amber|warm-kodak|cool-fuji|natural|none] // [--grain 0-20] [--bloom 0-0.4] [--vignette 0-1] [--dissolve 0.2-1.5] // [--letterbox none|2.39|2.00] [--crop none|9:16|1:1|4:5] // [--audio none|clips] [--music track.mp3] [--music-volume 0-1] // [--only 1,2,5] [--max-shot 3.5] [--no-title] [--no-end-card] // [--crf 10-30] [--max-mb 8] [--dry-run] // // Requires ffmpeg and ffprobe on PATH. Node 18 or newer. No npm packages. import { spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' import { num, parseFlags, renderCard } from './text-card.mjs' const FLAGS = new Set(['film', 'out', 'title-font', 'small-font', 'grade', 'grain', 'bloom', 'vignette', 'dissolve', 'letterbox', 'crop', 'audio', 'music', 'music-volume', 'only', 'max-shot', 'no-title', 'no-end-card', 'crf', 'max-mb', 'dry-run']) const SWITCHES = new Set(['no-title', 'no-end-card', 'dry-run']) // Grade presets. Each is a plain ffmpeg filter chain; see references/film-look.md for what they do. export const GRADES = { 'teal-amber': "colorbalance=rs=-0.06:gs=0.01:bs=0.06:rm=0.03:gm=-0.01:bm=-0.03:rh=0.05:gh=-0.01:bh=-0.05,curves=master='0/0.035 0.25/0.21 0.5/0.5 0.78/0.80 1/0.95',eq=saturation=0.9", 'warm-kodak': "colorbalance=rs=0.03:gs=0:bs=-0.03:rm=0.04:gm=0.01:bm=-0.04:rh=0.03:gh=0.01:bh=-0.04,curves=master='0/0.03 0.3/0.28 0.7/0.74 1/0.96',eq=saturation=1.03", 'cool-fuji': "colorbalance=rs=-0.03:gs=0.02:bs=0.04:rm=-0.02:gm=0.02:bm=0.01:rh=0.01:gh=0.01:bh=0.02,curves=master='0/0.04 0.3/0.29 0.7/0.72 1/0.95',eq=saturation=0.88", natural: "curves=master='0/0.02 0.25/0.23 0.75/0.77 1/0.98'", none: 'null', } function fail(message) { console.error(message) process.exit(1) } function run(cmd, args, label) { const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) if (r.error?.code === 'ENOENT') fail(`${cmd} is not installed or not on PATH. Install ffmpeg (it includes ffprobe) and try again.`) if (r.status !== 0) fail(`${label} failed:\n${(r.stderr || '').split('\n').slice(-15).join('\n')}`) return r.stdout } function probe(path) { const out = run('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', path], `ffprobe ${path}`) const j = JSON.parse(out) const v = j.streams.find((s) => s.codec_type === 'video') if (!v) fail(`${path} has no video stream. Pass the MP4 clips Seedance returned, not stills.`) return { width: v.width, height: v.height, duration: Number(j.format.duration), hasAudio: j.streams.some((s) => s.codec_type === 'audio') } } const even = (n) => Math.max(2, Math.round(n / 2) * 2) const f3 = (n) => Number(n).toFixed(3) function main() { let opts try { const argv = process.argv.slice(2) // Switches take no value; turn them into "--x true" pairs before strict parsing. const expanded = [] for (let i = 0; i < argv.length; i++) { expanded.push(argv[i]) if (argv[i].startsWith('--') && SWITCHES.has(argv[i].slice(2))) expanded.push('true') } opts = parseFlags(expanded, FLAGS) } catch (error) { fail(error.message) } if (!opts.film || !opts.out) fail('Usage: node scripts/assemble-film.mjs --film film.json --out film.mp4 --title-font path/to/font.ttf [options]. See the header of this file for every option.') if (!existsSync(opts.film)) fail(`Shot list not found: ${opts.film}. Create it with scripts/build-shot-list.mjs.`) let film try { film = JSON.parse(readFileSync(opts.film, 'utf8')) } catch { fail(`${opts.film} is not valid JSON.`) } const base = dirname(resolve(opts.film)) const rel = (p) => (isAbsolute(p) ? p : join(base, p)) const look = film.look ?? {} let grade, grain, bloom, vignette, dissolve, crf, maxMb, maxShot, musicVolume try { grade = opts.grade ?? look.grade ?? 'natural' if (!(grade in GRADES)) throw new Error(`--grade must be one of ${Object.keys(GRADES).join(', ')}, not "${grade}".`) grain = num(opts.grain ?? look.grain, 'grain', 0, 20, 6) bloom = num(opts.bloom ?? look.bloom, 'bloom', 0, 0.4, 0.12) vignette = num(opts.vignette ?? look.vignette, 'vignette', 0, 1, 0.35) dissolve = num(opts.dissolve ?? look.dissolve, 'dissolve', 0.2, 1.5, 0.6) crf = num(opts.crf, 'crf', 10, 30, 20) maxMb = opts['max-mb'] === undefined ? null : num(opts['max-mb'], 'max-mb', 1, 500) maxShot = opts['max-shot'] === undefined ? null : num(opts['max-shot'], 'max-shot', 1, 30) musicVolume = num(opts['music-volume'], 'music-volume', 0, 1, 0.8) } catch (error) { fail(error.message) } const letterbox = opts.letterbox ?? look.letterbox ?? 'none' if (!['none', '2.39', '2.00'].includes(String(letterbox))) fail(`--letterbox must be none, 2.39 or 2.00, not "${letterbox}".`) const crop = opts.crop ?? 'none' if (!['none', '9:16', '1:1', '4:5'].includes(crop)) fail(`--crop must be none, 9:16, 1:1 or 4:5, not "${crop}".`) if (crop !== 'none' && letterbox !== 'none') fail('Use either --crop or --letterbox, not both.') const audioMode = opts.audio ?? 'none' if (!['none', 'clips'].includes(audioMode)) fail(`--audio must be none or clips, not "${audioMode}".`) if (opts.music && !existsSync(opts.music)) fail(`Music file not found: ${opts.music}. Use an MP3 or WAV you have the rights to.`) if (opts.music && !/\.(mp3|wav|m4a|aac|flac)$/i.test(opts.music)) fail(`--music must be an audio file (.mp3, .wav, .m4a, .aac or .flac) that you own or licensed, not ${opts.music}.`) // Pick and validate shots. if (!Array.isArray(film.shots) || !film.shots.length) fail('The shot list has no shots.') let shots = film.shots.filter((s) => s.clip && !s.skip) if (opts.only) { const wanted = opts.only.split(',').map((x) => Number(x.trim())) if (wanted.some((n) => !Number.isInteger(n))) fail(`--only takes shot numbers like 1,2,5, not "${opts.only}".`) shots = wanted.map((n) => { const s = film.shots.find((x) => x.n === n) if (!s) fail(`--only: there is no shot number ${n} in the shot list.`) if (!s.clip) fail(`--only: shot ${n} has no clip yet. Generate and approve it first.`) return s }) } if (!shots.length) fail('No shot has a "clip" path yet. Add the approved Seedance MP4 paths to the shot list first.') const clips = shots.map((s) => { const path = rel(s.clip) if (!existsSync(path)) fail(`Shot ${s.n}: clip not found at ${path}.`) if (!/\.(mp4|mov|webm)$/i.test(path)) fail(`Shot ${s.n}: "${s.clip}" is not a video. Put the MP4 that Seedance returned in "clip"; the still belongs in "keyframe".`) const info = probe(path) const inT = Number(s.in ?? 0) let outT = Number(s.out ?? info.duration) if (!Number.isFinite(inT) || !Number.isFinite(outT) || inT < 0 || outT <= inT) fail(`Shot ${s.n}: "in" and "out" must be seconds with in < out.`) if (outT > info.duration + 0.05) fail(`Shot ${s.n}: "out" (${outT}s) is past the end of the clip (${info.duration.toFixed(2)}s).`) outT = Math.min(outT, info.duration) if (maxShot && outT - inT > maxShot) outT = inT + maxShot const speed = s.speed === undefined ? 1 : Number(s.speed) if (!Number.isFinite(speed) || speed < 0.5 || speed > 1) fail(`Shot ${s.n}: "speed" must be from 0.5 (half-speed slow motion) to 1 (normal).`) return { shot: s, path, info, inT, outT, speed, len: (outT - inT) / speed } }) // Output frame size: the smallest clip. Never upscale. const W = Math.min(...clips.map((c) => c.info.width)) const H = Math.min(...clips.map((c) => c.info.height)) const ratios = new Set(clips.map((c) => (c.info.width / c.info.height).toFixed(2))) if (ratios.size > 1) fail('The clips have different aspect ratios. Generate every shot at the same aspect (all 16:9 or all 9:16).') if (audioMode === 'clips') for (const c of clips) if (!c.info.hasAudio) fail(`Shot ${c.shot.n} has no audio. Use --audio none, or regenerate with generate_audio: true.`) let outW = W let outH = H if (crop !== 'none') { const [a, b] = crop.split(':').map(Number) outW = even(Math.min(W, (H * a) / b)) outH = even(Math.min(H, (outW * b) / a)) } const tmp = mkdtempSync(join(tmpdir(), 'assemble-film-')) const inputs = [] const graph = [] clips.forEach((c, i) => { inputs.push('-i', c.path) // speed < 1: slow motion with motion-compensated in-between frames (clean down to about 0.7). const slow = c.speed < 1 ? `,setpts=PTS/${c.speed},minterpolate=fps=24:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1` : '' graph.push(`[${i}:v]trim=start=${f3(c.inT)}:end=${f3(c.outT)},setpts=PTS-STARTPTS${slow},fps=24,scale=${W}:${H}:flags=lanczos,setsar=1,format=yuv420p,settb=1/24[s${i}]`) }) // Joins. const starts = [0] let acc = 's0' let accDur = clips[0].len const joins = [] for (let i = 1; i < clips.length; i++) { const t = clips[i].shot.transition_in === 'dissolve' ? 'dissolve' : 'cut' const next = `j${i}` if (t === 'dissolve') { const d = Math.min(dissolve, clips[i].len / 2, clips[i - 1].len / 2) graph.push(`[${acc}][s${i}]xfade=transition=fade:duration=${f3(d)}:offset=${f3(accDur - d)},settb=1/24[${next}]`) starts.push(accDur - d) joins.push({ at: accDur - d, type: 'dissolve', d }) accDur += clips[i].len - d } else { graph.push(`[${acc}][s${i}]concat=n=2:v=1:a=0,settb=1/24[${next}]`) starts.push(accDur) joins.push({ at: accDur, type: 'cut', d: 0 }) accDur += clips[i].len } acc = next } // Bloom (soft halation on highlights), then grade, then crop. let chain = acc if (bloom > 0) { graph.push(`[${chain}]split[bm0][bm1];[bm1]format=gbrp,curves=all='0/0 0.62/0 1/1',gblur=sigma=${Math.round(H / 36)}[bm2];[bm0]format=gbrp[bm3];[bm3][bm2]blend=all_mode=screen:all_opacity=${bloom},format=yuv420p[bloomed]`) chain = 'bloomed' } graph.push(`[${chain}]${GRADES[grade]},format=yuv420p[graded]`) chain = 'graded' if (crop !== 'none') { graph.push(`[${chain}]crop=${outW}:${outH}:(iw-${outW})/2:(ih-${outH})/2[cropped]`) chain = 'cropped' } // Title card. const titleFont = opts['title-font'] const smallFont = opts['small-font'] ?? titleFont const tc = film.title_card ?? {} const wantTitle = !opts['no-title'] && tc.title && tc.title.trim() const ec = film.end_card ?? {} const wantEnd = !opts['no-end-card'] && ec.text && ec.text.trim() if ((wantTitle || wantEnd) && !titleFont) fail('Pass --title-font path/to/font.ttf for the title and end cards (or --no-title --no-end-card). references/film-look.md lists free fonts that work.') let inputIndex = clips.length let total = accDur try { if (wantTitle) { const onShot = Number(tc.on_shot ?? shots[0].n) const idx = clips.findIndex((c) => c.shot.n === onShot) if (idx === -1) fail(`title_card.on_shot is ${onShot} but that shot is not in this cut. Use --no-title or include the shot.`) const ts = starts[idx] + num(tc.start, 'title_card.start', 0, 20, 0.8) const td = num(tc.duration, 'title_card.duration', 1.5, 12, 3.4) const te = Math.min(ts + td, accDur - 0.2) const png = join(tmp, 'title.png') const lines = [] if (tc.kicker) lines.push({ text: tc.kicker, font: smallFont, size: 0.026, tracking: 0.32, opacity: 0.9 }) lines.push({ text: tc.title, font: titleFont, size: num(tc.size, 'title_card.size', 0.04, 0.3, 0.11), tracking: 0.01 }) if (tc.subline) lines.push({ text: tc.subline, font: smallFont, size: 0.024, tracking: 0.3, opacity: 0.85 }) writeFileSync(png, renderCard({ width: outW, height: outH, lines, color: tc.color ?? '#F3ECE0', position: tc.position ?? 'center', shadow: 0.35 })) inputs.push('-loop', '1', '-framerate', '24', '-t', f3(te + 0.5), '-i', png) const fd = Math.min(0.9, (te - ts) / 3) graph.push(`[${inputIndex}:v]format=rgba,fade=t=in:st=${f3(ts)}:d=${f3(fd)}:alpha=1,fade=t=out:st=${f3(te - fd)}:d=${f3(fd)}:alpha=1[tt];[${chain}][tt]overlay=0:0:eof_action=pass,format=yuv420p,fps=24,settb=1/24[titled]`) chain = 'titled' inputIndex++ } // End card on near-black, joined with a fade through black. if (wantEnd) { const ed = num(ec.duration, 'end_card.duration', 1.5, 8, 3) const png = join(tmp, 'end.png') const lines = [{ text: ec.text, font: titleFont, size: num(ec.size, 'end_card.size', 0.03, 0.2, 0.075), tracking: 0.02 }] if (ec.subline) lines.push({ text: ec.subline, font: smallFont, size: 0.024, tracking: 0.3, opacity: 0.85 }) writeFileSync(png, renderCard({ width: outW, height: outH, lines, color: ec.color ?? '#F3ECE0', position: 'center', shadow: 0 })) inputs.push('-loop', '1', '-framerate', '24', '-t', f3(ed), '-i', png) const fadeD = Math.min(1, ed / 3) graph.push(`color=c=0x0A0A0A:s=${outW}x${outH}:r=24:d=${f3(ed)},format=yuv420p,setsar=1[ecb];[${inputIndex}:v]format=rgba,fade=t=in:st=${f3(fadeD * 0.6)}:d=${f3(fadeD)}:alpha=1,fade=t=out:st=${f3(ed - 0.7)}:d=0.7:alpha=1[et];[ecb][et]overlay=0:0,format=yuv420p,fps=24,settb=1/24[ec]`) graph.push(`[${chain}]setsar=1,fps=24,settb=1/24[pre];[pre][ec]xfade=transition=fadeblack:duration=${f3(fadeD)}:offset=${f3(accDur - fadeD)}[ended]`) joins.push({ at: accDur - fadeD, type: 'end-card', d: fadeD }) total = accDur - fadeD + ed chain = 'ended' inputIndex++ } } catch (error) { rmSync(tmp, { recursive: true, force: true }) fail(error.message) } // Grain, vignette, letterbox. const post = [] if (grain > 0) post.push(`noise=c0s=${grain}:c0f=t+u`) if (vignette > 0) post.push(`vignette=angle=${(0.15 + 0.5 * vignette).toFixed(3)}`) if (letterbox !== 'none') { const bar = Math.max(0, Math.round((outH - outW / Number(letterbox)) / 2)) post.push(`drawbox=x=0:y=0:w=iw:h=${bar}:color=black:t=fill,drawbox=x=0:y=ih-${bar}:w=iw:h=${bar}:color=black:t=fill`) } post.push('format=yuv420p') graph.push(`[${chain}]${post.join(',')}[vout]`) // Audio. let audioLabel = null if (audioMode === 'clips') { clips.forEach((c, i) => graph.push(`[${i}:a]atrim=start=${f3(c.inT)}:end=${f3(c.outT)},asetpts=PTS-STARTPTS${c.speed < 1 ? `,atempo=${c.speed}` : ''},aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,afade=t=in:d=0.04,afade=t=out:st=${f3(Math.max(0, c.len - 0.04))}:d=0.04[ca${i}]`)) let a = 'ca0' for (let i = 1; i < clips.length; i++) { const j = joins[i - 1] graph.push(j.type === 'dissolve' ? `[${a}][ca${i}]acrossfade=d=${f3(j.d)}[aj${i}]` : `[${a}][ca${i}]concat=n=2:v=0:a=1[aj${i}]`) a = `aj${i}` } graph.push(`[${a}]apad=whole_dur=${f3(total)},atrim=0:${f3(total)},afade=t=out:st=${f3(Math.max(0, total - 1.5))}:d=1.5,volume=${opts.music ? 0.55 : 1}[clipaudio]`) audioLabel = 'clipaudio' } if (opts.music) { inputs.push('-stream_loop', '-1', '-i', opts.music) graph.push(`[${inputIndex}:a]atrim=0:${f3(total)},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,volume=${musicVolume},afade=t=in:d=1,afade=t=out:st=${f3(Math.max(0, total - 2.5))}:d=2.5[music]`) if (audioLabel) { graph.push(`[${audioLabel}][music]amix=inputs=2:duration=first:normalize=0[aout]`) audioLabel = 'aout' } else audioLabel = 'music' } const filterGraph = graph.join(';') const common = ['-y', '-hide_banner', '-v', 'error', ...inputs, '-filter_complex', filterGraph, '-map', '[vout]'] const video = ['-c:v', 'libx264', '-preset', 'slow', '-tune', 'grain', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-r', '24'] const audio = audioLabel ? ['-map', `[${audioLabel}]`, '-c:a', 'aac', '-b:a', '160k'] : ['-an'] const tail = ['-t', f3(total), '-movflags', '+faststart'] if (opts['dry-run']) { console.log(`Inputs: ${inputs.filter((x) => !x.startsWith('-')).join(' ')}\n\nFilter graph:\n${graph.join(';\n')}\n\nDuration: ${total.toFixed(2)} s, ${outW}x${outH}`) rmSync(tmp, { recursive: true, force: true }) return } if (maxMb) { const audioKbps = audioLabel ? 160 : 0 const videoKbps = Math.floor((maxMb * 8 * 1024 * 0.94) / total - audioKbps) if (videoKbps < 600) fail(`--max-mb ${maxMb} is too small for ${total.toFixed(1)} s at this size. Raise it or cut a shorter preview.`) const passlog = join(tmp, 'pass') run('ffmpeg', [...common, ...video, '-b:v', `${videoKbps}k`, '-pass', '1', '-passlogfile', passlog, '-an', '-t', f3(total), '-f', 'mp4', process.platform === 'win32' ? 'NUL' : '/dev/null'], 'ffmpeg pass 1') run('ffmpeg', [...common, ...video, '-b:v', `${videoKbps}k`, '-maxrate', `${Math.round(videoKbps * 1.5)}k`, '-bufsize', `${videoKbps * 2}k`, '-pass', '2', '-passlogfile', passlog, ...audio, ...tail, opts.out], 'ffmpeg pass 2') } else { run('ffmpeg', [...common, ...video, '-crf', String(crf), ...audio, ...tail, opts.out], 'ffmpeg') } rmSync(tmp, { recursive: true, force: true }) const edl = { file: opts.out, width: outW, height: outH, duration: Number(total.toFixed(3)), grade, grain, bloom, vignette, letterbox, crop, shots: clips.map((c, i) => ({ n: c.shot.n, start: Number(starts[i].toFixed(3)), length: Number(c.len.toFixed(3)), in: c.inT, out: Number(c.outT.toFixed(3)), speed: c.speed })), joins: joins.map((j) => ({ ...j, at: Number(j.at.toFixed(3)), d: Number(j.d.toFixed(3)) })) } writeFileSync(`${opts.out}.edl.json`, JSON.stringify(edl, null, 2)) const mb = statSync(opts.out).size / 1024 / 1024 console.log(`${opts.out} ${outW}x${outH} ${total.toFixed(2)} s ${mb.toFixed(2)} MB grade=${grade}\nEdit list: ${opts.out}.edl.json\nNext: node scripts/check-film.mjs --file ${opts.out} --edl ${opts.out}.edl.json`) } main()