/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / server / takes.ts
173 lines · 7.1 KBCodeBlameHistory
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 1// Take lifecycle: create -> append chunks while recording -> finalize (remux, probe, peaks, thumbnail).
3import fs from 'node:fs/promises';
4import path from 'node:path';
5import type { Take, TrackKind } from '../shared/types.ts';
6import { randomId } from '../shared/timeline.ts';
7import { computePeaks, extractFrame, probe, remux } from './media.ts';
8import { HttpError, exists, getConfig, getVideo, takeDir, updateVideo, videoDir } from './store.ts';
6805bfcRecord computer audio alongside the microphoneJeremy Magland 9import { finalizeSystemAudio, startSystemCapture, stopSystemCapture, systemCaptureEvent } from './sysaudio.ts';
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 10import { transcribeTake } from './transcribe.ts';
12const TRACKS: TrackKind[] = ['screen', 'camera'];
6805bfcRecord computer audio alongside the microphoneJeremy Magland 14export async function createTake(videoId: string, input: { tracks: { screen: boolean; camera: boolean }; audio: TrackKind | null; systemAudio?: boolean }): Promise<Take> {
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 15 const tracks = { screen: !!input.tracks?.screen, camera: !!input.tracks?.camera };
16 if (!tracks.screen && !tracks.camera) throw new HttpError(400, 'A take needs a screen or camera track');
17 const audio = input.audio && tracks[input.audio] ? input.audio : null;
18 const d = new Date();
19 const take: Take = {
20 id: `t${d.getTime().toString(36)}${randomId(3)}`,
21 createdAt: d.toISOString(),
22 status: 'recording',
23 tracks,
24 audio,
6805bfcRecord computer audio alongside the microphoneJeremy Magland 25 ...(input.systemAudio === true ? { systemAudio: true } : {}),
27 };
28 await fs.mkdir(takeDir(videoId, take.id), { recursive: true });
6805bfcRecord computer audio alongside the microphoneJeremy Magland 29 if (take.systemAudio) {
30 try {
31 await startSystemCapture(videoId, take.id);
32 } catch (e) {
33 await fs.rm(takeDir(videoId, take.id), { recursive: true, force: true });
34 throw new HttpError(500, (e as Error).message);
35 }
36 }
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 37 await updateVideo(videoId, (v) => {
38 v.takes.push(take);
39 });
40 return take;
43const partFile = (videoId: string, takeId: string, track: TrackKind) => path.join(takeDir(videoId, takeId), `${track}.part.webm`);
45export async function appendChunk(videoId: string, takeId: string, track: string, data: Buffer) {
46 if (track !== 'screen' && track !== 'camera') throw new HttpError(400, 'Invalid track');
47 const v = await getVideo(videoId);
48 const take = v.takes.find((t) => t.id === takeId);
49 if (!take) throw new HttpError(404, 'Take not found');
50 if (take.status !== 'recording') throw new HttpError(409, 'Take is not recording');
51 if (!take.tracks[track]) throw new HttpError(400, `Take has no ${track} track`);
52 await fs.appendFile(partFile(videoId, takeId, track), data);
6805bfcRecord computer audio alongside the microphoneJeremy Magland 53 if (take.systemAudio) await systemCaptureEvent(videoId, takeId, null);
56const finalizing = new Set<string>();
58export async function finalizeTake(videoId: string, takeId: string) {
59 const key = `${videoId}/${takeId}`;
60 if (finalizing.has(key)) throw new HttpError(409, 'Already finalizing');
61 finalizing.add(key);
62 try {
63 const v = await getVideo(videoId);
64 const take = v.takes.find((t) => t.id === takeId);
65 if (!take) throw new HttpError(404, 'Take not found');
66 if (take.status === 'ready') return v;
67 await updateVideo(videoId, (v) => {
68 const t = v.takes.find((t) => t.id === takeId)!;
69 t.status = 'processing';
70 });
72 const dir = takeDir(videoId, takeId);
73 const durations: number[] = [];
74 try {
75 for (const track of TRACKS) {
76 if (!take.tracks[track]) continue;
77 const part = partFile(videoId, takeId, track);
78 const final = path.join(dir, `${track}.webm`);
79 if (await exists(part)) {
80 await remux(part, final);
81 await fs.rm(part);
82 }
83 if (!(await exists(final))) throw new Error(`No ${track} data was recorded`);
84 durations.push((await probe(final)).duration);
85 }
86 const duration = Math.min(...durations);
87 if (!(duration > 0)) throw new Error('Recording is empty');
6805bfcRecord computer audio alongside the microphoneJeremy Magland 89 // Computer audio is optional: if it cannot be aligned, keep the take without it.
90 let systemAudio = false;
91 if (take.systemAudio) {
92 systemAudio = await finalizeSystemAudio(videoId, takeId, duration).catch((e) => {
93 console.warn('system audio:', e.message);
94 return false;
95 });
96 }
98 // The waveform shows the microphone, or the computer audio when there is no microphone.
99 const audioFile = take.audio ? path.join(dir, `${take.audio}.webm`) : systemAudio ? path.join(dir, 'system.webm') : null;
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 100 if (audioFile) await computePeaks(audioFile, path.join(dir, 'peaks.json')).catch((e) => console.warn('peaks:', e.message));
102 const updated = await updateVideo(videoId, (v) => {
103 const t = v.takes.find((t) => t.id === takeId)!;
104 t.status = 'ready';
105 t.duration = duration;
6805bfcRecord computer audio alongside the microphoneJeremy Magland 106 if (systemAudio) t.systemAudio = true;
107 else delete t.systemAudio;
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 108 delete t.error;
109 v.timeline.push({ id: randomId(), takeId, start: 0, end: duration });
110 });
111 if (!updated.thumbnail) await autoThumbnail(videoId);
112 maybeAutoTranscribe(videoId, takeId, !!take.audio);
113 return getVideo(videoId);
114 } catch (e) {
115 await updateVideo(videoId, (v) => {
116 const t = v.takes.find((t) => t.id === takeId);
117 if (t) {
118 t.status = 'failed';
119 t.error = (e as Error).message;
120 }
121 });
122 throw e;
123 }
124 } finally {
125 finalizing.delete(key);
126 }
129async function maybeAutoTranscribe(videoId: string, takeId: string, hasAudio: boolean) {
130 if (!hasAudio) return;
131 const cfg = await getConfig();
132 if (cfg.autoTranscribe && cfg.deepgramApiKey) transcribeTake(videoId, takeId).catch((e) => console.warn('transcribe:', e.message));
135// Thumbnail from the first timeline segment, preferring the screen track.
136export async function autoThumbnail(videoId: string) {
137 const v = await getVideo(videoId);
138 const seg = v.timeline[0];
139 if (!seg) return;
140 const take = v.takes.find((t) => t.id === seg.takeId);
141 if (!take) return;
142 const track: TrackKind = take.tracks.screen ? 'screen' : 'camera';
143 const t = Math.min(seg.start + 1.5, (seg.start + seg.end) / 2);
144 try {
145 await extractFrame(path.join(takeDir(videoId, take.id), `${track}.webm`), t, path.join(videoDir(videoId), 'thumbnail.jpg'));
146 await updateVideo(videoId, (v) => {
147 v.thumbnail = { file: 'thumbnail.jpg', custom: false, updatedAt: new Date().toISOString() };
148 });
149 } catch (e) {
150 console.warn('thumbnail:', (e as Error).message);
151 }
154export async function setThumbnail(videoId: string, jpeg: Buffer) {
155 await getVideo(videoId);
156 await fs.writeFile(path.join(videoDir(videoId), 'thumbnail.jpg'), jpeg);
157 return updateVideo(videoId, (v) => {
158 v.thumbnail = { file: 'thumbnail.jpg', custom: true, updatedAt: new Date().toISOString() };
159 });
162export async function deleteTake(videoId: string, takeId: string) {
6805bfcRecord computer audio alongside the microphoneJeremy Magland 163 await stopSystemCapture(videoId, takeId);
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 164 const updated = await updateVideo(videoId, (v) => {
165 if (!v.takes.some((t) => t.id === takeId)) throw new HttpError(404, 'Take not found');
166 v.takes = v.takes.filter((t) => t.id !== takeId);
167 v.timeline = v.timeline.filter((s) => s.takeId !== takeId);
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 168 if (v.crops) delete v.crops[takeId];
170 await fs.rm(takeDir(videoId, takeId), { recursive: true, force: true });
171 if (updated.thumbnail && !updated.thumbnail.custom) await autoThumbnail(videoId);
172 return getVideo(videoId);
↑↓ move↵openescclose