// Take lifecycle: create -> append chunks while recording -> finalize (remux, probe, peaks, thumbnail). import fs from 'node:fs/promises'; import path from 'node:path'; import type { Take, TrackKind } from '../shared/types.ts'; import { randomId } from '../shared/timeline.ts'; import { computePeaks, extractFrame, probe, remux } from './media.ts'; import { HttpError, exists, getConfig, getVideo, takeDir, updateVideo, videoDir } from './store.ts'; import { transcribeTake } from './transcribe.ts'; const TRACKS: TrackKind[] = ['screen', 'camera']; export async function createTake(videoId: string, input: { tracks: { screen: boolean; camera: boolean }; audio: TrackKind | null }): Promise { const tracks = { screen: !!input.tracks?.screen, camera: !!input.tracks?.camera }; if (!tracks.screen && !tracks.camera) throw new HttpError(400, 'A take needs a screen or camera track'); const audio = input.audio && tracks[input.audio] ? input.audio : null; const d = new Date(); const take: Take = { id: `t${d.getTime().toString(36)}${randomId(3)}`, createdAt: d.toISOString(), status: 'recording', tracks, audio, duration: 0, }; await fs.mkdir(takeDir(videoId, take.id), { recursive: true }); await updateVideo(videoId, (v) => { v.takes.push(take); }); return take; } const partFile = (videoId: string, takeId: string, track: TrackKind) => path.join(takeDir(videoId, takeId), `${track}.part.webm`); export async function appendChunk(videoId: string, takeId: string, track: string, data: Buffer) { if (track !== 'screen' && track !== 'camera') throw new HttpError(400, 'Invalid track'); const v = await getVideo(videoId); const take = v.takes.find((t) => t.id === takeId); if (!take) throw new HttpError(404, 'Take not found'); if (take.status !== 'recording') throw new HttpError(409, 'Take is not recording'); if (!take.tracks[track]) throw new HttpError(400, `Take has no ${track} track`); await fs.appendFile(partFile(videoId, takeId, track), data); } const finalizing = new Set(); export async function finalizeTake(videoId: string, takeId: string) { const key = `${videoId}/${takeId}`; if (finalizing.has(key)) throw new HttpError(409, 'Already finalizing'); finalizing.add(key); try { const v = await getVideo(videoId); const take = v.takes.find((t) => t.id === takeId); if (!take) throw new HttpError(404, 'Take not found'); if (take.status === 'ready') return v; await updateVideo(videoId, (v) => { const t = v.takes.find((t) => t.id === takeId)!; t.status = 'processing'; }); const dir = takeDir(videoId, takeId); const durations: number[] = []; try { for (const track of TRACKS) { if (!take.tracks[track]) continue; const part = partFile(videoId, takeId, track); const final = path.join(dir, `${track}.webm`); if (await exists(part)) { await remux(part, final); await fs.rm(part); } if (!(await exists(final))) throw new Error(`No ${track} data was recorded`); durations.push((await probe(final)).duration); } const duration = Math.min(...durations); if (!(duration > 0)) throw new Error('Recording is empty'); const audioFile = take.audio ? path.join(dir, `${take.audio}.webm`) : null; if (audioFile) await computePeaks(audioFile, path.join(dir, 'peaks.json')).catch((e) => console.warn('peaks:', e.message)); const updated = await updateVideo(videoId, (v) => { const t = v.takes.find((t) => t.id === takeId)!; t.status = 'ready'; t.duration = duration; delete t.error; v.timeline.push({ id: randomId(), takeId, start: 0, end: duration }); }); if (!updated.thumbnail) await autoThumbnail(videoId); maybeAutoTranscribe(videoId, takeId, !!take.audio); return getVideo(videoId); } catch (e) { await updateVideo(videoId, (v) => { const t = v.takes.find((t) => t.id === takeId); if (t) { t.status = 'failed'; t.error = (e as Error).message; } }); throw e; } } finally { finalizing.delete(key); } } async function maybeAutoTranscribe(videoId: string, takeId: string, hasAudio: boolean) { if (!hasAudio) return; const cfg = await getConfig(); if (cfg.autoTranscribe && cfg.deepgramApiKey) transcribeTake(videoId, takeId).catch((e) => console.warn('transcribe:', e.message)); } // Thumbnail from the first timeline segment, preferring the screen track. export async function autoThumbnail(videoId: string) { const v = await getVideo(videoId); const seg = v.timeline[0]; if (!seg) return; const take = v.takes.find((t) => t.id === seg.takeId); if (!take) return; const track: TrackKind = take.tracks.screen ? 'screen' : 'camera'; const t = Math.min(seg.start + 1.5, (seg.start + seg.end) / 2); try { await extractFrame(path.join(takeDir(videoId, take.id), `${track}.webm`), t, path.join(videoDir(videoId), 'thumbnail.jpg')); await updateVideo(videoId, (v) => { v.thumbnail = { file: 'thumbnail.jpg', custom: false, updatedAt: new Date().toISOString() }; }); } catch (e) { console.warn('thumbnail:', (e as Error).message); } } export async function setThumbnail(videoId: string, jpeg: Buffer) { await getVideo(videoId); await fs.writeFile(path.join(videoDir(videoId), 'thumbnail.jpg'), jpeg); return updateVideo(videoId, (v) => { v.thumbnail = { file: 'thumbnail.jpg', custom: true, updatedAt: new Date().toISOString() }; }); } export async function deleteTake(videoId: string, takeId: string) { const updated = await updateVideo(videoId, (v) => { if (!v.takes.some((t) => t.id === takeId)) throw new HttpError(404, 'Take not found'); v.takes = v.takes.filter((t) => t.id !== takeId); v.timeline = v.timeline.filter((s) => s.takeId !== takeId); }); await fs.rm(takeDir(videoId, takeId), { recursive: true, force: true }); if (updated.thumbnail && !updated.thumbnail.custom) await autoThumbnail(videoId); return getVideo(videoId); }