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