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';
9import { transcribeTake } from './transcribe.ts';
11const TRACKS: TrackKind[] = ['screen', 'camera'];
13export async function createTake(videoId: string, input: { tracks: { screen: boolean; camera: boolean }; audio: TrackKind | null }): Promise<Take> {
14 const tracks = { screen: !!input.tracks?.screen, camera: !!input.tracks?.camera };
15 if (!tracks.screen && !tracks.camera) throw new HttpError(400, 'A take needs a screen or camera track');
16 const audio = input.audio && tracks[input.audio] ? input.audio : null;
17 const d = new Date();
18 const take: Take = {
19 id: `t${d.getTime().toString(36)}${randomId(3)}`,
20 createdAt: d.toISOString(),
21 status: 'recording',
22 tracks,
23 audio,
24 duration: 0,
25 };
26 await fs.mkdir(takeDir(videoId, take.id), { recursive: true });
27 await updateVideo(videoId, (v) => {
28 v.takes.push(take);
29 });
30 return take;
31}
33const partFile = (videoId: string, takeId: string, track: TrackKind) => path.join(takeDir(videoId, takeId), `${track}.part.webm`);
35export async function appendChunk(videoId: string, takeId: string, track: string, data: Buffer) {
36 if (track !== 'screen' && track !== 'camera') throw new HttpError(400, 'Invalid track');
37 const v = await getVideo(videoId);
38 const take = v.takes.find((t) => t.id === takeId);
39 if (!take) throw new HttpError(404, 'Take not found');
40 if (take.status !== 'recording') throw new HttpError(409, 'Take is not recording');
41 if (!take.tracks[track]) throw new HttpError(400, `Take has no ${track} track`);
42 await fs.appendFile(partFile(videoId, takeId, track), data);
43}
45const finalizing = new Set<string>();
47export async function finalizeTake(videoId: string, takeId: string) {
48 const key = `${videoId}/${takeId}`;
49 if (finalizing.has(key)) throw new HttpError(409, 'Already finalizing');
50 finalizing.add(key);
51 try {
52 const v = await getVideo(videoId);
53 const take = v.takes.find((t) => t.id === takeId);
54 if (!take) throw new HttpError(404, 'Take not found');
55 if (take.status === 'ready') return v;
56 await updateVideo(videoId, (v) => {
57 const t = v.takes.find((t) => t.id === takeId)!;
58 t.status = 'processing';
59 });
61 const dir = takeDir(videoId, takeId);
62 const durations: number[] = [];
63 try {
64 for (const track of TRACKS) {
65 if (!take.tracks[track]) continue;
66 const part = partFile(videoId, takeId, track);
67 const final = path.join(dir, `${track}.webm`);
68 if (await exists(part)) {
69 await remux(part, final);
70 await fs.rm(part);
71 }
72 if (!(await exists(final))) throw new Error(`No ${track} data was recorded`);
73 durations.push((await probe(final)).duration);
74 }
75 const duration = Math.min(...durations);
76 if (!(duration > 0)) throw new Error('Recording is empty');
78 const audioFile = take.audio ? path.join(dir, `${take.audio}.webm`) : null;
79 if (audioFile) await computePeaks(audioFile, path.join(dir, 'peaks.json')).catch((e) => console.warn('peaks:', e.message));
81 const updated = await updateVideo(videoId, (v) => {
82 const t = v.takes.find((t) => t.id === takeId)!;
83 t.status = 'ready';
84 t.duration = duration;
85 delete t.error;
86 v.timeline.push({ id: randomId(), takeId, start: 0, end: duration });
87 });
88 if (!updated.thumbnail) await autoThumbnail(videoId);
89 maybeAutoTranscribe(videoId, takeId, !!take.audio);
90 return getVideo(videoId);
91 } catch (e) {
92 await updateVideo(videoId, (v) => {
93 const t = v.takes.find((t) => t.id === takeId);
94 if (t) {
95 t.status = 'failed';
96 t.error = (e as Error).message;
97 }
98 });
99 throw e;
100 }
101 } finally {
102 finalizing.delete(key);
103 }
104}
106async function maybeAutoTranscribe(videoId: string, takeId: string, hasAudio: boolean) {
107 if (!hasAudio) return;
108 const cfg = await getConfig();
109 if (cfg.autoTranscribe && cfg.deepgramApiKey) transcribeTake(videoId, takeId).catch((e) => console.warn('transcribe:', e.message));
110}
112// Thumbnail from the first timeline segment, preferring the screen track.
113export async function autoThumbnail(videoId: string) {
114 const v = await getVideo(videoId);
115 const seg = v.timeline[0];
116 if (!seg) return;
117 const take = v.takes.find((t) => t.id === seg.takeId);
118 if (!take) return;
119 const track: TrackKind = take.tracks.screen ? 'screen' : 'camera';
120 const t = Math.min(seg.start + 1.5, (seg.start + seg.end) / 2);
121 try {
122 await extractFrame(path.join(takeDir(videoId, take.id), `${track}.webm`), t, path.join(videoDir(videoId), 'thumbnail.jpg'));
123 await updateVideo(videoId, (v) => {
124 v.thumbnail = { file: 'thumbnail.jpg', custom: false, updatedAt: new Date().toISOString() };
125 });
126 } catch (e) {
127 console.warn('thumbnail:', (e as Error).message);
128 }
129}
131export async function setThumbnail(videoId: string, jpeg: Buffer) {
132 await getVideo(videoId);
133 await fs.writeFile(path.join(videoDir(videoId), 'thumbnail.jpg'), jpeg);
134 return updateVideo(videoId, (v) => {
135 v.thumbnail = { file: 'thumbnail.jpg', custom: true, updatedAt: new Date().toISOString() };
136 });
137}
139export async function deleteTake(videoId: string, takeId: string) {
140 const updated = await updateVideo(videoId, (v) => {
141 if (!v.takes.some((t) => t.id === takeId)) throw new HttpError(404, 'Take not found');
142 v.takes = v.takes.filter((t) => t.id !== takeId);
143 v.timeline = v.timeline.filter((s) => s.takeId !== takeId);
144 if (v.crops) delete v.crops[takeId];
145 });
146 await fs.rm(takeDir(videoId, takeId), { recursive: true, force: true });
147 if (updated.thumbnail && !updated.thumbnail.custom) await autoThumbnail(videoId);
148 return getVideo(videoId);
149}