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, Video } 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';
9import { finalizeSystemAudio, startSystemCapture, stopSystemCapture, systemCaptureEvent } from './sysaudio.ts';
10import { transcribeTake } from './transcribe.ts';
12const TRACKS: TrackKind[] = ['screen', 'camera'];
14export async function createTake(videoId: string, input: { tracks: { screen: boolean; camera: boolean }; audio: TrackKind | null; systemAudio?: boolean }): Promise<Take> {
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,
25 ...(input.systemAudio === true ? { systemAudio: true } : {}),
26 duration: 0,
27 };
28 await fs.mkdir(takeDir(videoId, take.id), { recursive: true });
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 }
37 await updateVideo(videoId, (v) => {
38 v.takes.push(take);
39 });
40 return take;
41}
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);
53 if (take.systemAudio) await systemCaptureEvent(videoId, takeId, null);
54}
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 let screenSize: { width: number; height: number } | undefined;
75 try {
76 for (const track of TRACKS) {
77 if (!take.tracks[track]) continue;
78 const part = partFile(videoId, takeId, track);
79 const final = path.join(dir, `${track}.webm`);
80 if (await exists(part)) {
81 await remux(part, final);
82 await fs.rm(part);
83 }
84 if (!(await exists(final))) throw new Error(`No ${track} data was recorded`);
85 const info = await probe(final);
86 durations.push(info.duration);
87 if (track === 'screen' && info.width && info.height) screenSize = { width: info.width, height: info.height };
88 }
89 const duration = Math.min(...durations);
90 if (!(duration > 0)) throw new Error('Recording is empty');
92 // Computer audio is optional: if it cannot be aligned, keep the take without it.
93 let systemAudio = false;
94 if (take.systemAudio) {
95 systemAudio = await finalizeSystemAudio(videoId, takeId, duration).catch((e) => {
96 console.warn('system audio:', e.message);
97 return false;
98 });
99 }
101 // The waveform shows the microphone, or the computer audio when there is no microphone.
102 const audioFile = take.audio ? path.join(dir, `${take.audio}.webm`) : systemAudio ? path.join(dir, 'system.webm') : null;
103 if (audioFile) await computePeaks(audioFile, path.join(dir, 'peaks.json')).catch((e) => console.warn('peaks:', e.message));
105 const updated = await updateVideo(videoId, (v) => {
106 const t = v.takes.find((t) => t.id === takeId)!;
107 t.status = 'ready';
108 t.duration = duration;
109 if (screenSize) t.screenSize = screenSize;
110 if (systemAudio) t.systemAudio = true;
111 else delete t.systemAudio;
112 delete t.error;
113 v.timeline.push({ id: randomId(), takeId, start: 0, end: duration });
114 });
115 if (!updated.thumbnail) await autoThumbnail(videoId);
116 maybeAutoTranscribe(videoId, takeId, !!take.audio);
117 return getVideo(videoId);
118 } catch (e) {
119 await updateVideo(videoId, (v) => {
120 const t = v.takes.find((t) => t.id === takeId);
121 if (t) {
122 t.status = 'failed';
123 t.error = (e as Error).message;
124 }
125 });
126 throw e;
127 }
128 } finally {
129 finalizing.delete(key);
130 }
131}
133async function maybeAutoTranscribe(videoId: string, takeId: string, hasAudio: boolean) {
134 if (!hasAudio) return;
135 const cfg = await getConfig();
136 if (cfg.autoTranscribe && cfg.deepgramApiKey) transcribeTake(videoId, takeId).catch((e) => console.warn('transcribe:', e.message));
137}
139// Thumbnail from the first timeline segment, preferring the screen track.
140export async function autoThumbnail(videoId: string) {
141 const v = await getVideo(videoId);
142 const seg = v.timeline[0];
143 if (!seg) return;
144 const take = v.takes.find((t) => t.id === seg.takeId);
145 if (!take) return;
146 const track: TrackKind = take.tracks.screen ? 'screen' : 'camera';
147 const t = Math.min(seg.start + 1.5, (seg.start + seg.end) / 2);
148 try {
149 await extractFrame(path.join(takeDir(videoId, take.id), `${track}.webm`), t, path.join(videoDir(videoId), 'thumbnail.jpg'));
150 await updateVideo(videoId, (v) => {
151 v.thumbnail = { file: 'thumbnail.jpg', custom: false, updatedAt: new Date().toISOString() };
152 });
153 } catch (e) {
154 console.warn('thumbnail:', (e as Error).message);
155 }
156}
158export async function setThumbnail(videoId: string, jpeg: Buffer) {
159 await getVideo(videoId);
160 await fs.writeFile(path.join(videoDir(videoId), 'thumbnail.jpg'), jpeg);
161 return updateVideo(videoId, (v) => {
162 v.thumbnail = { file: 'thumbnail.jpg', custom: true, updatedAt: new Date().toISOString() };
163 });
164}
166export async function deleteTake(videoId: string, takeId: string) {
167 await stopSystemCapture(videoId, takeId);
168 const updated = await updateVideo(videoId, (v) => {
169 if (!v.takes.some((t) => t.id === takeId)) throw new HttpError(404, 'Take not found');
170 v.takes = v.takes.filter((t) => t.id !== takeId);
171 v.timeline = v.timeline.filter((s) => s.takeId !== takeId);
172 if (v.crops) delete v.crops[takeId];
173 });
174 await fs.rm(takeDir(videoId, takeId), { recursive: true, force: true });
175 if (updated.thumbnail && !updated.thumbnail.custom) await autoThumbnail(videoId);
176 return getVideo(videoId);
177}
179// The screen size of takes recorded before it was stored, probed once from screen.webm and saved,
180// so the output settings can say which resolution shows the screen at full size.
181export async function backfillScreenSizes(videoId: string): Promise<Video> {
182 const v = await getVideo(videoId);
183 const missing = v.takes.filter((t) => t.status === 'ready' && t.tracks.screen && !t.screenSize);
184 if (missing.length === 0) return v;
185 const sizes = new Map<string, { width: number; height: number }>();
186 for (const t of missing) {
187 const info = await probe(path.join(takeDir(videoId, t.id), 'screen.webm')).catch(() => null);
188 if (info?.width && info.height) sizes.set(t.id, { width: info.width, height: info.height });
189 }
190 if (sizes.size === 0) return v;
191 return updateVideo(videoId, (v) => {
192 for (const t of v.takes) {
193 const size = sizes.get(t.id);
194 if (size && !t.screenSize) t.screenSize = size;
195 }
196 }, { touch: false });
197}