f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 1// Render the timeline to an MP4 with ffmpeg.
2//
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 3// Each segment becomes a sub-graph: a black canvas of the output size, the screen track (cropped, if set) fit
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 4// into its box, the camera track (optionally masked to a rounded rect or circle) overlaid, and
5// the mic audio trimmed/padded to exactly the segment length. The segments are then concatenated.
7import fs from 'node:fs/promises';
8import path from 'node:path';
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 9import type { Crop, ExportJob, Video } from '../shared/types.ts';
10import { cropOf, exportSignature } from '../shared/types.ts';
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 11import { FPS, computeGeometry } from '../shared/layout.ts';
12import { timelineDuration } from '../shared/timeline.ts';
13import { fileSize, run } from './media.ts';
14import { HttpError, getVideo, slugify, takeDir, updateVideo, videoDir } from './store.ts';
16const jobs = new Map<string, ExportJob>();
18export const getExportJob = (videoId: string) => jobs.get(videoId) ?? null;
20const f3 = (x: number) => x.toFixed(3);
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 22// Crop filter for a rectangle given as fractions of the frame (ffmpeg rounds to the chroma grid), or nothing.
23const cropFilter = (c: Crop | undefined) => (c ? `crop=w=iw*${c.w.toFixed(5)}:h=ih*${c.h.toFixed(5)}:x=iw*${c.x.toFixed(5)}:y=ih*${c.y.toFixed(5)},` : '');
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 25// Alpha mask for a w x h box with corner radius r (r = w/2 on a square gives a circle), anti-aliased.
26function alphaExpr(r: number) {
27 return `255*clip(${r}-hypot(max(abs(X+0.5-W/2)-(W/2-${r}),0),max(abs(Y+0.5-H/2)-(H/2-${r}),0))+0.5,0,1)`;
28}
30export function buildExportArgs(v: Video, outFile: string): string[] {
31 if (v.timeline.length === 0) throw new HttpError(400, 'The timeline is empty');
32 const inputs: string[] = [];
33 const filters: string[] = [];
34 let concat = '';
35 let nIn = 0;
36 const addInput = (file: string, start: number, dur: number, decoder?: string) => {
37 inputs.push(...(decoder ? ['-c:v', decoder] : []), '-ss', f3(start), '-t', f3(dur), '-i', file);
38 return nIn++;
39 };
41 v.timeline.forEach((seg, i) => {
42 const take = v.takes.find((t) => t.id === seg.takeId);
43 if (!take || take.status !== 'ready') throw new HttpError(400, `Timeline refers to a missing or unfinished take (${seg.takeId})`);
44 const D = seg.end - seg.start;
45 const dir = takeDir(v.id, take.id);
46 const g = computeGeometry(v.layout, v.output, take.tracks.screen, take.tracks.camera);
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 47 const screenCrop = cropFilter(cropOf(v, take.id, 'screen'));
48 const cameraCrop = cropFilter(cropOf(v, take.id, 'camera'));
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 49 const idx: Record<string, number> = {};
50 const bgMode = g.camera?.background ?? 'none';
51 if (bgMode !== 'none' && take.matte?.status !== 'done') {
52 throw new HttpError(409, `Background removal has not finished for take ${v.takes.indexOf(take) + 1}`);
53 }
54 if (g.screen) idx.screen = addInput(path.join(dir, 'screen.webm'), seg.start, D);
55 // The cutout has an alpha channel, which only the libvpx decoder preserves.
56 if (bgMode !== 'none') idx.cutout = addInput(path.join(dir, 'cutout.webm'), seg.start, D, 'libvpx-vp9');
57 if (g.camera && (bgMode === 'none' || bgMode === 'blur')) idx.camera = addInput(path.join(dir, 'camera.webm'), seg.start, D);
58 if (take.audio && idx[take.audio] === undefined) idx[take.audio] = addInput(path.join(dir, `${take.audio}.webm`), seg.start, D);
60 filters.push(`color=c=black:s=${g.W}x${g.H}:r=${FPS}:d=${f3(D)}[bg${i}]`);
61 let cur = `bg${i}`;
62 if (g.screen) {
63 const b = g.screen;
64 filters.push(
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 65 `[${idx.screen}:v]fps=${FPS}:start_time=0,${screenCrop}scale=${b.w}:${b.h}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 66 `pad=${b.w}:${b.h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[s${i}]`,
67 );
68 filters.push(`[${cur}][s${i}]overlay=${b.x}:${b.y}:eof_action=repeat[os${i}]`);
69 cur = `os${i}`;
70 }
71 if (g.camera) {
72 const { box: b, fit, radius, background, color, blurSigma } = g.camera;
73 const fitTo = (transparentPad: boolean) =>
74 fit === 'cover'
75 ? `scale=${b.w}:${b.h}:force_original_aspect_ratio=increase,crop=${b.w}:${b.h}`
76 : `scale=${b.w}:${b.h}:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=${b.w}:${b.h}:(ow-iw)/2:(oh-ih)/2:color=${transparentPad ? 'black@0' : 'black'}`;
77 if (background === 'none') {
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 78 filters.push(`[${idx.camera}:v]fps=${FPS}:start_time=0,${cameraCrop}${fitTo(false)},setsar=1[cam${i}]`);
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 80 filters.push(`[${idx.cutout}:v]fps=${FPS}:start_time=0,format=yuva420p,${cameraCrop}${fitTo(true)},setsar=1[fg${i}]`);
81 if (background === 'blur') filters.push(`[${idx.camera}:v]fps=${FPS}:start_time=0,${cameraCrop}${fitTo(false)},setsar=1,gblur=sigma=${blurSigma}[cb${i}]`);
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 82 if (background === 'color') filters.push(`color=c=0x${color.replace('#', '')}:s=${b.w}x${b.h}:r=${FPS}:d=${f3(D)},setsar=1[cb${i}]`);
83 if (background === 'cutout') filters.push(`[fg${i}]null[cam${i}]`);
84 else filters.push(`[cb${i}][fg${i}]overlay=0:0:eof_action=repeat[cam${i}]`);
85 }
86 if (radius > 0) {
87 // Compute the mask for a single frame and loop it (a per-frame geq is several times slower).
88 filters.push(
89 `color=c=black:s=${b.w}x${b.h}:r=${FPS}:d=1,format=gray,geq=lum='${alphaExpr(radius)}',trim=end_frame=1,` +
90 `loop=loop=-1:size=1:start=0,setpts=N/${FPS}/TB,trim=duration=${f3(D)}[m${i}]`,
91 );
92 filters.push(`[cam${i}]format=yuva420p[cm${i}]`);
93 filters.push(`[cm${i}][m${i}]alphamerge[c${i}]`);
94 } else {
95 filters.push(`[cam${i}]null[c${i}]`);
96 }
97 filters.push(`[${cur}][c${i}]overlay=${b.x}:${b.y}:eof_action=repeat[oc${i}]`);
98 cur = `oc${i}`;
99 }
100 filters.push(`[${cur}]format=yuv420p[v${i}]`);
102 const fade = Math.min(0.015, D / 4);
103 if (take.audio) {
104 filters.push(
105 `[${idx[take.audio]}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,apad,atrim=0:${f3(D)},asetpts=PTS-STARTPTS,` +
106 `afade=t=in:d=${f3(fade)},afade=t=out:st=${f3(D - fade)}:d=${f3(fade)}[a${i}]`,
107 );
108 } else {
109 filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${f3(D)}[a${i}]`);
110 }
111 concat += `[v${i}][a${i}]`;
112 });
114 filters.push(`${concat}concat=n=${v.timeline.length}:v=1:a=1[vout][acat]`);
115 filters.push(v.output.normalizeAudio ? `[acat]loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000[aout]` : `[acat]anull[aout]`);
117 return [
118 '-y', '-v', 'error', '-nostats', '-progress', 'pipe:1',
119 ...inputs,
120 '-filter_complex', filters.join(';'),
121 '-map', '[vout]', '-map', '[aout]',
122 '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-r', String(FPS),
123 '-c:a', 'aac', '-b:a', '160k',
124 '-movflags', '+faststart',
125 outFile,
126 ];
127}
129export async function startExport(videoId: string): Promise<ExportJob> {
130 const running = jobs.get(videoId);
131 if (running?.status === 'running') return running;
132 const v = await getVideo(videoId);
133 const signature = exportSignature(v);
134 const duration = timelineDuration(v.timeline);
135 const name = `${slugify(v.title) || 'video'}.mp4`;
136 const dir = videoDir(videoId);
137 const tmp = path.join(dir, `.export-${Date.now()}.mp4`);
138 const args = buildExportArgs(v, tmp);
140 const job: ExportJob = { status: 'running', progress: 0, startedAt: new Date().toISOString() };
141 jobs.set(videoId, job);
143 (async () => {
144 try {
145 let buf = '';
146 await run('ffmpeg', args, {
147 onStdout: (d) => {
148 buf += d;
149 const lines = buf.split('\n');
150 buf = lines.pop() ?? '';
151 for (const line of lines) {
152 const m = /^out_time_us=(\d+)/.exec(line);
153 if (m && duration > 0) job.progress = Math.min(0.99, parseInt(m[1]) / 1e6 / duration);
154 }
155 },
156 });
157 const prev = (await getVideo(videoId)).export;
158 await fs.rename(tmp, path.join(dir, name));
159 if (prev && prev.file !== name) await fs.rm(path.join(dir, prev.file), { force: true });
160 const bytes = await fileSize(path.join(dir, name));
161 await updateVideo(videoId, (v) => {
162 v.export = { file: name, exportedAt: new Date().toISOString(), signature, duration, bytes };
163 });
164 job.status = 'done';
165 job.progress = 1;
166 } catch (e) {
167 job.status = 'failed';
168 job.error = (e as Error).message;
169 console.warn('export failed:', job.error);
170 await fs.rm(tmp, { force: true });
171 }
172 })();
173 return job;
174}