/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / server / export.ts
183 lines · 8.9 KBCodeBlameHistory
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';
6805bfcRecord computer audio alongside the microphoneJeremy Magland 10import { cropOf, exportSignature, systemVolumeOf } 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)`;
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);
6805bfcRecord computer audio alongside the microphoneJeremy Magland 59 const sysVol = systemVolumeOf(v.output);
60 if (take.systemAudio && sysVol > 0) idx.system = addInput(path.join(dir, 'system.webm'), seg.start, D);
62 filters.push(`color=c=black:s=${g.W}x${g.H}:r=${FPS}:d=${f3(D)}[bg${i}]`);
63 let cur = `bg${i}`;
64 if (g.screen) {
65 const b = g.screen;
66 filters.push(
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 67 `[${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 68 `pad=${b.w}:${b.h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[s${i}]`,
69 );
70 filters.push(`[${cur}][s${i}]overlay=${b.x}:${b.y}:eof_action=repeat[os${i}]`);
71 cur = `os${i}`;
72 }
73 if (g.camera) {
74 const { box: b, fit, radius, background, color, blurSigma } = g.camera;
75 const fitTo = (transparentPad: boolean) =>
76 fit === 'cover'
77 ? `scale=${b.w}:${b.h}:force_original_aspect_ratio=increase,crop=${b.w}:${b.h}`
78 : `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'}`;
79 if (background === 'none') {
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 80 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 82 filters.push(`[${idx.cutout}:v]fps=${FPS}:start_time=0,format=yuva420p,${cameraCrop}${fitTo(true)},setsar=1[fg${i}]`);
83 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 84 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}]`);
85 if (background === 'cutout') filters.push(`[fg${i}]null[cam${i}]`);
86 else filters.push(`[cb${i}][fg${i}]overlay=0:0:eof_action=repeat[cam${i}]`);
87 }
88 if (radius > 0) {
89 // Compute the mask for a single frame and loop it (a per-frame geq is several times slower).
90 filters.push(
91 `color=c=black:s=${b.w}x${b.h}:r=${FPS}:d=1,format=gray,geq=lum='${alphaExpr(radius)}',trim=end_frame=1,` +
92 `loop=loop=-1:size=1:start=0,setpts=N/${FPS}/TB,trim=duration=${f3(D)}[m${i}]`,
93 );
94 filters.push(`[cam${i}]format=yuva420p[cm${i}]`);
95 filters.push(`[cm${i}][m${i}]alphamerge[c${i}]`);
96 } else {
97 filters.push(`[cam${i}]null[c${i}]`);
98 }
99 filters.push(`[${cur}][c${i}]overlay=${b.x}:${b.y}:eof_action=repeat[oc${i}]`);
100 cur = `oc${i}`;
101 }
102 filters.push(`[${cur}]format=yuv420p[v${i}]`);
6805bfcRecord computer audio alongside the microphoneJeremy Magland 104 // Microphone and computer audio, each trimmed/padded to the segment length, summed, with short fades against clicks.
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 105 const fade = Math.min(0.015, D / 4);
6805bfcRecord computer audio alongside the microphoneJeremy Magland 106 const fit = `aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,apad,atrim=0:${f3(D)},asetpts=PTS-STARTPTS`;
107 const fades = `afade=t=in:d=${f3(fade)},afade=t=out:st=${f3(D - fade)}:d=${f3(fade)}`;
108 const sources: string[] = [];
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 109 if (take.audio) {
6805bfcRecord computer audio alongside the microphoneJeremy Magland 110 filters.push(`[${idx[take.audio]}:a]${fit}[am${i}]`);
111 sources.push(`[am${i}]`);
112 }
113 if (idx.system !== undefined) {
114 filters.push(`[${idx.system}:a]${fit},volume=${sysVol.toFixed(3)}[as${i}]`);
115 sources.push(`[as${i}]`);
6805bfcRecord computer audio alongside the microphoneJeremy Magland 117 if (sources.length === 2) filters.push(`${sources.join('')}amix=inputs=2:duration=first:normalize=0,${fades}[a${i}]`);
118 else if (sources.length === 1) filters.push(`${sources[0]}${fades}[a${i}]`);
119 else filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${f3(D)}[a${i}]`);
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 120 concat += `[v${i}][a${i}]`;
121 });
123 filters.push(`${concat}concat=n=${v.timeline.length}:v=1:a=1[vout][acat]`);
124 filters.push(v.output.normalizeAudio ? `[acat]loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000[aout]` : `[acat]anull[aout]`);
126 return [
127 '-y', '-v', 'error', '-nostats', '-progress', 'pipe:1',
128 ...inputs,
129 '-filter_complex', filters.join(';'),
130 '-map', '[vout]', '-map', '[aout]',
131 '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-r', String(FPS),
132 '-c:a', 'aac', '-b:a', '160k',
133 '-movflags', '+faststart',
134 outFile,
135 ];
138export async function startExport(videoId: string): Promise<ExportJob> {
139 const running = jobs.get(videoId);
140 if (running?.status === 'running') return running;
141 const v = await getVideo(videoId);
142 const signature = exportSignature(v);
143 const duration = timelineDuration(v.timeline);
144 const name = `${slugify(v.title) || 'video'}.mp4`;
145 const dir = videoDir(videoId);
146 const tmp = path.join(dir, `.export-${Date.now()}.mp4`);
147 const args = buildExportArgs(v, tmp);
149 const job: ExportJob = { status: 'running', progress: 0, startedAt: new Date().toISOString() };
150 jobs.set(videoId, job);
152 (async () => {
153 try {
154 let buf = '';
155 await run('ffmpeg', args, {
156 onStdout: (d) => {
157 buf += d;
158 const lines = buf.split('\n');
159 buf = lines.pop() ?? '';
160 for (const line of lines) {
161 const m = /^out_time_us=(\d+)/.exec(line);
162 if (m && duration > 0) job.progress = Math.min(0.99, parseInt(m[1]) / 1e6 / duration);
163 }
164 },
165 });
166 const prev = (await getVideo(videoId)).export;
167 await fs.rename(tmp, path.join(dir, name));
168 if (prev && prev.file !== name) await fs.rm(path.join(dir, prev.file), { force: true });
169 const bytes = await fileSize(path.join(dir, name));
170 await updateVideo(videoId, (v) => {
171 v.export = { file: name, exportedAt: new Date().toISOString(), signature, duration, bytes };
172 });
173 job.status = 'done';
174 job.progress = 1;
175 } catch (e) {
176 job.status = 'failed';
177 job.error = (e as Error).message;
178 console.warn('export failed:', job.error);
179 await fs.rm(tmp, { force: true });
180 }
181 })();
182 return job;
↑↓ move↵openescclose