f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 1// Data model shared by the server and the browser UI.
3export type TrackKind = 'screen' | 'camera';
5export interface Take {
6 id: string;
7 createdAt: string;
8 status: 'recording' | 'processing' | 'ready' | 'failed';
9 tracks: { screen: boolean; camera: boolean };
10 // Which track file carries the microphone audio (null if no mic was recorded).
11 audio: TrackKind | null;
6805bfcRecord computer audio alongside the microphoneJeremy Magland 12 // Computer audio (what the speakers played), in system.webm, aligned with the other tracks.
13 systemAudio?: boolean;
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 14 // Seconds; 0 until the take is finalized.
15 duration: number;
16 transcript?: { status: 'pending' | 'done' | 'failed'; error?: string; updatedAt: string };
17 // Background removal of the camera track (cutout.webm, VP9 with alpha). queued/running are live
18 // states reported by the server; only done/failed are persisted.
19 matte?: { status: 'queued' | 'running' | 'done' | 'failed'; progress?: number; error?: string; updatedAt: string };
20 error?: string;
21}
23// A piece of a take, [start, end) in take time (seconds). The timeline is an ordered list of these.
24export interface Segment {
25 id: string;
26 takeId: string;
27 start: number;
28 end: number;
29}
31export type LayoutMode = 'pip' | 'side-by-side' | 'screen-only' | 'camera-only';
32export type Corner = 'tl' | 'tr' | 'bl' | 'br';
33export type CameraShape = 'rect' | 'rounded' | 'circle';
34export type BackgroundMode = 'none' | 'blur' | 'color' | 'cutout';
36// What replaces the room behind the speaker (requires background removal of the camera takes).
37export interface Background {
38 mode: BackgroundMode;
39 color: string; // #rrggbb, for 'color'
40 blur: number; // Gaussian sigma in pixels at 1080p output, for 'blur'
41}
43export const DEFAULT_BACKGROUND: Background = { mode: 'none', color: '#1e2530', blur: 18 };
45export const backgroundOf = (l: Layout): Background => ({ ...DEFAULT_BACKGROUND, ...l.background });
47// How screen and camera are combined in takes that have both.
48export interface Layout {
49 mode: LayoutMode;
50 corner: Corner; // PiP corner; for side-by-side, the left/right side of the camera column
51 size: number; // PiP width as a fraction of the output width
52 shape: CameraShape;
53 background?: Background; // absent in videos created before background removal existed
54}
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 56// A crop rectangle as fractions of the source frame, so it does not depend on the recorded resolution.
57export interface Crop {
58 x: number;
59 y: number;
60 w: number;
61 h: number;
62}
64// Per-take crops of the raw tracks; a missing track means the full frame.
65export type TakeCrop = Partial<Record<TrackKind, Crop>>;
67export const cropOf = (v: Pick<Video, 'crops'>, takeId: string, track: TrackKind): Crop | undefined => v.crops?.[takeId]?.[track];
69export const isFullFrame = (c: Crop) => c.x < 0.001 && c.y < 0.001 && c.w > 0.999 && c.h > 0.999;
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 71export interface OutputSettings {
72 height: 720 | 1080;
73 normalizeAudio: boolean;
6805bfcRecord computer audio alongside the microphoneJeremy Magland 74 systemVolume?: number; // gain applied to computer audio in the mix, 0..1 (default 1)
6805bfcRecord computer audio alongside the microphoneJeremy Magland 77export const systemVolumeOf = (o: OutputSettings) => o.systemVolume ?? 1;
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 79export interface ExportInfo {
80 file: string;
81 exportedAt: string;
82 signature: string; // exportSignature() at the time of export, used to detect stale exports
83 duration: number;
84 bytes: number;
85}
87export interface Video {
88 id: string;
89 title: string;
90 description: string;
91 notes: string;
92 createdAt: string;
93 updatedAt: string;
94 takes: Take[];
95 timeline: Segment[];
96 layout: Layout;
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 97 crops?: Record<string, TakeCrop>; // by take id; absent in videos created before cropping existed
99 thumbnail: { file: string; custom: boolean; updatedAt: string } | null;
100 export: ExportInfo | null;
101}
103export interface VideoSummary {
104 id: string;
105 title: string;
106 description: string;
107 createdAt: string;
108 updatedAt: string;
109 duration: number;
110 takeCount: number;
111 hasScreen: boolean;
112 hasCamera: boolean;
113 thumbnailUrl: string | null;
114 exportState: 'none' | 'current' | 'stale';
115}
117export interface ExportJob {
118 status: 'running' | 'done' | 'failed';
119 progress: number; // 0..1
120 startedAt: string;
121 error?: string;
122}
124export interface AppConfig {
125 deepgramModel: string;
126 autoTranscribe: boolean;
127 defaultCountdown: number;
128 defaultCameraHeight: 720 | 1080;
129 defaultOutputHeight: 720 | 1080;
307b4e2Run background removal on a remote GPU over sshJeremy Magland 130 matteHost: string; // ssh destination for background removal on a GPU; empty for this computer
131 matteSetup: string; // shell commands run on that host first, e.g. "module load python ffmpeg"
134export interface PublicConfig extends AppConfig {
135 hasDeepgramKey: boolean;
136 libraryDir: string;
137}
139export interface Word {
140 word: string;
141 text: string; // punctuated form for display
142 start: number;
143 end: number;
144}
146export interface Transcript {
147 takeId: string;
148 model: string;
149 createdAt: string;
150 text: string;
151 words: Word[];
152}
154// Audio peaks for waveform display: `rate` values per second, each 0..255, base64-encoded bytes.
155export interface Peaks {
156 rate: number;
157 data: string;
158}
6805bfcRecord computer audio alongside the microphoneJeremy Magland 160export function exportSignature(v: Pick<Video, 'timeline' | 'layout' | 'output' | 'crops' | 'takes'>): string {
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 161 const seg = v.timeline.map((s) => [s.takeId, round3(s.start), round3(s.end)]);
162 const l = v.layout;
163 const o = v.output;
164 const b = backgroundOf(l);
165 // The background is omitted when off so that exports made before it existed stay current.
166 const bg = b.mode === 'color' ? ['color', b.color] : b.mode === 'blur' ? ['blur', b.blur] : b.mode;
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 167 // Likewise, crops are included only for takes in the timeline that have one.
168 const crop: Record<string, (number[] | null)[]> = {};
169 for (const id of new Set(v.timeline.map((s) => s.takeId))) {
170 const c = v.crops?.[id];
171 const r = (x?: Crop) => (x ? [round3(x.x), round3(x.y), round3(x.w), round3(x.h)] : null);
172 if (c?.screen || c?.camera) crop[id] = [r(c.screen), r(c.camera)];
173 }
174 return JSON.stringify({
175 seg,
176 layout: [l.mode, l.corner, round3(l.size), l.shape],
177 ...(b.mode === 'none' ? {} : { bg }),
178 ...(Object.keys(crop).length ? { crop } : {}),
6805bfcRecord computer audio alongside the microphoneJeremy Magland 179 // And the computer audio level only matters when a take in the timeline has computer audio.
180 ...(v.timeline.some((s) => v.takes.find((t) => t.id === s.takeId)?.systemAudio) ? { sys: round3(systemVolumeOf(o)) } : {}),
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 181 output: [o.height, o.normalizeAudio],
182 });
185function round3(x: number) {
186 return Math.round(x * 1000) / 1000;
187}