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