#!/usr/bin/env node // Cuts the approved shot clips into one text-free teaser with ffmpeg: trims, cuts or dissolves, // one grade preset over every shot, optional bloom, grain and vignette, an optional centre crop // (e.g. 9:16 to 4:5 for a feed post), a short fade-out, then H.264 MP4 with +faststart. // No text is added: the user puts their own captions on in the app they post from. // // Usage: // node scripts/assemble-teaser.mjs --teaser teaser.json --out teaser.mp4 // [--grade teal-amber|warm-kodak|cool-fuji|blue-hour|natural|none] // [--grain 0-20] [--bloom 0-0.4] [--vignette 0-1] [--dissolve 0.2-1.5] [--fade-out 0-1.5] // [--crop none|9:16|4:5|1:1|16:9] [--audio none|clips] [--music track.mp3] [--music-volume 0-1] // [--only 1,2,5] [--max-shot 3.5] [--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' const FLAGS = new Set(['teaser', 'out', 'grade', 'grain', 'bloom', 'vignette', 'dissolve', 'fade-out', 'crop', 'audio', 'music', 'music-volume', 'only', 'max-shot', 'crf', 'max-mb', 'dry-run']) const SWITCHES = new Set(['dry-run']) // Grade presets: plain ffmpeg filter chains. See references/look-and-light.md. 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", 'blue-hour': "colorbalance=rs=-0.04:gs=0:bs=0.05:rm=-0.01:gm=0:bm=0.02:rh=0.06:gh=0.02:bh=-0.05,curves=master='0/0.03 0.25/0.22 0.6/0.6 1/0.96',eq=saturation=0.95", 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 parseFlags(argv) { const out = {} for (let i = 0; i < argv.length; i++) { const a = argv[i] if (!a.startsWith('--')) fail(`Unexpected argument "${a}". Flags look like --name value.`) const key = a.slice(2) if (!FLAGS.has(key)) fail(`Unknown flag --${key}. Allowed: ${[...FLAGS].map((f) => `--${f}`).join(', ')}`) if (SWITCHES.has(key)) { out[key] = true; continue } const value = argv[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) out[key] = value i++ } return out } function num(value, name, min, max, fallback) { if (value === undefined || value === null) return fallback const n = Number(value) if (!Number.isFinite(n) || n < min || n > max) fail(`${name} must be a number from ${min} to ${max}, got "${value}".`) return n } 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 j = JSON.parse(run('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', path], `ffprobe ${path}`)) 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.floor(n / 2) * 2) const f3 = (n) => Number(n).toFixed(3) const opts = parseFlags(process.argv.slice(2)) if (!opts.teaser || !opts.out) fail('Usage: node scripts/assemble-teaser.mjs --teaser teaser.json --out teaser.mp4 [options]. See the header of this file for every option.') if (!existsSync(opts.teaser)) fail(`Shot list not found: ${opts.teaser}. Create it with scripts/build-shot-list.mjs.`) let teaser try { teaser = JSON.parse(readFileSync(opts.teaser, 'utf8')) } catch { fail(`${opts.teaser} is not valid JSON.`) } const base = dirname(resolve(opts.teaser)) const rel = (p) => (isAbsolute(p) ? p : join(base, p)) const look = teaser.look ?? {} const grade = opts.grade ?? look.grade ?? 'natural' if (!(grade in GRADES)) fail(`--grade must be one of ${Object.keys(GRADES).join(', ')}, not "${grade}".`) const grain = num(opts.grain ?? look.grain, '--grain', 0, 20, 6) const bloom = num(opts.bloom ?? look.bloom, '--bloom', 0, 0.4, 0.1) const vignette = num(opts.vignette ?? look.vignette, '--vignette', 0, 1, 0.3) const dissolve = num(opts.dissolve ?? look.dissolve, '--dissolve', 0.2, 1.5, 0.5) const fadeOut = num(opts['fade-out'] ?? look.fade_out, '--fade-out', 0, 1.5, 0.5) const crf = num(opts.crf, '--crf', 10, 30, 20) const maxMb = opts['max-mb'] === undefined ? null : num(opts['max-mb'], '--max-mb', 1, 500) const maxShot = opts['max-shot'] === undefined ? null : num(opts['max-shot'], '--max-shot', 1, 30) const musicVolume = num(opts['music-volume'], '--music-volume', 0, 1, 0.8) const crop = opts.crop ?? 'none' if (!['none', '9:16', '4:5', '1:1', '16:9'].includes(crop)) fail(`--crop must be none, 9:16, 4:5, 1:1 or 16:9, not "${crop}".`) 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}.`) if (!Array.isArray(teaser.shots) || !teaser.shots.length) fail('The shot list has no shots.') let shots = teaser.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 = teaser.shots.find((x) => x.n === n) if (!s) fail(`--only: there is no shot number ${n}.`) if (!s.clip) fail(`--only: shot ${n} has no clip yet.`) 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. Use the MP4 that Seedance returned.`) 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 return { shot: s, path, info, inT, outT, len: outT - inT } }) const W = Math.min(...clips.map((c) => c.info.width)) const H = Math.min(...clips.map((c) => c.info.height)) if (new Set(clips.map((c) => (c.info.width / c.info.height).toFixed(2))).size > 1) fail('The clips have different aspect ratios. Generate every shot at the same aspect (all 9:16 or all 16:9).') 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) if (W / H > a / b) { outH = even(H); outW = even((H * a) / b) } else { outW = even(W); outH = even((W * b) / a) } } const tmp = mkdtempSync(join(tmpdir(), 'assemble-teaser-')) const inputs = [] const graph = [] clips.forEach((c, i) => { inputs.push('-i', c.path) graph.push(`[${i}:v]trim=start=${f3(c.inT)}:end=${f3(c.outT)},setpts=PTS-STARTPTS,fps=24,scale=${W}:${H}:flags=lanczos,setsar=1,format=yuv420p,settb=1/24[s${i}]`) }) const starts = [0] const joins = [] let acc = 's0' let total = clips[0].len for (let i = 1; i < clips.length; i++) { const next = `j${i}` if (clips[i].shot.transition_in === '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(total - d)},settb=1/24[${next}]`) starts.push(total - d) joins.push({ at: total - d, type: 'dissolve', d }) total += clips[i].len - d } else { graph.push(`[${acc}][s${i}]concat=n=2:v=1:a=0,settb=1/24[${next}]`) starts.push(total) joins.push({ at: total, type: 'cut', d: 0 }) total += clips[i].len } acc = next } 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(Math.min(W, H) / 36)}[bm2];[bm0]format=gbrp[bm3];[bm3][bm2]blend=all_mode=screen:all_opacity=${bloom},format=yuv420p[bloomed]`) chain = 'bloomed' } const post = [GRADES[grade]] if (crop !== 'none') post.push(`crop=${outW}:${outH}:(iw-${outW})/2:(ih-${outH})/2`) 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 (fadeOut > 0) post.push(`fade=t=out:st=${f3(total - fadeOut)}:d=${f3(fadeOut)}`) post.push('format=yuv420p') graph.push(`[${chain}]${post.join(',')}[vout]`) let audioLabel = null const inputIndex = clips.length if (audioMode === 'clips') { clips.forEach((c, i) => graph.push(`[${i}:a]atrim=start=${f3(c.inT)}:end=${f3(c.outT)},asetpts=PTS-STARTPTS,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))}:d=1,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=0.5,afade=t=out:st=${f3(Math.max(0, total - 1.5))}:d=1.5[music]`) if (audioLabel) { graph.push(`[${audioLabel}][music]amix=inputs=2:duration=first:normalize=0[aout]`) audioLabel = 'aout' } else audioLabel = 'music' } const common = ['-y', '-hide_banner', '-v', 'error', ...inputs, '-filter_complex', graph.join(';'), '-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(`Filter graph:\n${graph.join(';\n')}\n\nDuration: ${total.toFixed(2)} s, ${outW}x${outH}`) rmSync(tmp, { recursive: true, force: true }) process.exit(0) } 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. Raise it or cut a shorter version.`) 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, 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)) })), joins: [...joins.map((j) => ({ ...j, at: Number(j.at.toFixed(3)), d: Number(j.d.toFixed(3)) })), ...(fadeOut > 0 ? [{ at: Number((total - fadeOut).toFixed(3)), type: 'fade-out', d: fadeOut }] : [])] } 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-teaser.mjs --file ${opts.out} --edl ${opts.out}.edl.json`)