/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / server / store.ts
229 lines · 7.6 KBBlameHistoryRaw
1// On-disk library: one directory per video, each with a video.json.
2//
3// <root>/library.json { order: [videoId, ...] }
4// <root>/config.json app settings (incl. Deepgram key), mode 0600
5// <root>/<videoId>/video.json
6// <root>/<videoId>/thumbnail.jpg
7// <root>/<videoId>/takes/<takeId>/{screen,camera}.webm, peaks.json, transcript.json
8// <root>/.trash/ deleted videos are moved here
10import fs from 'node:fs/promises';
11import path from 'node:path';
12import type { AppConfig, Video, VideoSummary } from '../shared/types.ts';
13import { exportSignature } from '../shared/types.ts';
14import { randomId, timelineDuration } from '../shared/timeline.ts';
16export class HttpError extends Error {
17 status: number;
18 constructor(status: number, message: string) {
19 super(message);
20 this.status = status;
21 }
24let root = '';
26export async function initStore(dir: string) {
27 root = dir;
28 await fs.mkdir(root, { recursive: true });
31export const libraryDir = () => root;
32export const videoDir = (id: string) => path.join(root, checkId(id));
33export const takeDir = (id: string, takeId: string) => path.join(videoDir(id), 'takes', checkId(takeId));
35const ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
37export function checkId(id: string): string {
38 if (typeof id !== 'string' || !ID_RE.test(id)) throw new HttpError(400, `Invalid id: ${id}`);
39 return id;
42export async function exists(p: string) {
43 try {
44 await fs.access(p);
45 return true;
46 } catch {
47 return false;
48 }
51async function readJson<T>(file: string, fallback: T): Promise<T> {
52 try {
53 return JSON.parse(await fs.readFile(file, 'utf8')) as T;
54 } catch (e) {
55 if ((e as NodeJS.ErrnoException).code === 'ENOENT') return fallback;
56 throw e;
57 }
60export async function writeJsonAtomic(file: string, data: unknown, mode = 0o644) {
61 const tmp = `${file}.${randomId(6)}.tmp`;
62 await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode });
63 await fs.rename(tmp, file);
66// Serialize read-modify-write cycles per key.
67const locks = new Map<string, Promise<unknown>>();
68function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
69 const prev = locks.get(key) ?? Promise.resolve();
70 const next = prev.then(fn, fn);
71 const settled = next.catch(() => {});
72 locks.set(key, settled);
73 settled.then(() => {
74 if (locks.get(key) === settled) locks.delete(key);
75 });
76 return next;
79// ---- config ----
81const DEFAULT_CONFIG: AppConfig & { deepgramApiKey: string } = {
82 deepgramApiKey: '',
83 deepgramModel: 'nova-3',
84 autoTranscribe: true,
85 defaultCountdown: 3,
86 defaultCameraHeight: 720,
87 defaultOutputHeight: 1080,
88 matteHost: '',
89 matteSetup: '',
90};
92export type FullConfig = typeof DEFAULT_CONFIG;
94export async function getConfig(): Promise<FullConfig> {
95 return { ...DEFAULT_CONFIG, ...(await readJson<Partial<FullConfig>>(path.join(root, 'config.json'), {})) };
98export function updateConfig(patch: Partial<FullConfig>): Promise<FullConfig> {
99 return withLock('__config__', async () => {
100 const cfg = await getConfig();
101 for (const k of Object.keys(DEFAULT_CONFIG) as (keyof FullConfig)[]) {
102 if (patch[k] !== undefined) (cfg as unknown as Record<string, unknown>)[k] = patch[k];
103 }
104 await writeJsonAtomic(path.join(root, 'config.json'), cfg, 0o600);
105 return cfg;
106 });
109// ---- videos ----
111export async function getVideo(id: string): Promise<Video> {
112 const v = await readJson<Video | null>(path.join(videoDir(id), 'video.json'), null);
113 if (!v) throw new HttpError(404, `Video not found: ${id}`);
114 return v;
117export function updateVideo(id: string, fn: (v: Video) => void | Promise<void>): Promise<Video> {
118 return withLock(id, async () => {
119 const v = await getVideo(id);
120 await fn(v);
121 v.updatedAt = new Date().toISOString();
122 await writeJsonAtomic(path.join(videoDir(id), 'video.json'), v);
123 return v;
124 });
127function newVideoId(title: string) {
128 const d = new Date();
129 const pad = (n: number) => String(n).padStart(2, '0');
130 const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
131 const slug = slugify(title).slice(0, 40);
132 return [stamp, slug, randomId(4)].filter(Boolean).join('-');
135export function slugify(s: string) {
136 return s
137 .toLowerCase()
138 .normalize('NFKD')
139 .replace(/[^a-z0-9]+/g, '-')
140 .replace(/^-+|-+$/g, '');
143export async function createVideo(input: { title?: string; description?: string; notes?: string }): Promise<Video> {
144 const cfg = await getConfig();
145 const now = new Date().toISOString();
146 const title = (input.title ?? '').trim() || 'Untitled video';
147 const v: Video = {
148 id: newVideoId(title),
149 title,
150 description: input.description ?? '',
151 notes: input.notes ?? '',
152 createdAt: now,
153 updatedAt: now,
154 takes: [],
155 timeline: [],
156 layout: { mode: 'pip', corner: 'br', size: 0.22, shape: 'rounded' },
157 output: { height: cfg.defaultOutputHeight, normalizeAudio: true },
158 thumbnail: null,
159 export: null,
160 };
161 await fs.mkdir(path.join(videoDir(v.id), 'takes'), { recursive: true });
162 await writeJsonAtomic(path.join(videoDir(v.id), 'video.json'), v);
163 await withLock('__library__', async () => {
164 const lib = await readLibrary();
165 lib.order.push(v.id);
166 await writeJsonAtomic(path.join(root, 'library.json'), lib);
167 });
168 return v;
171async function readLibrary() {
172 return readJson<{ order: string[] }>(path.join(root, 'library.json'), { order: [] });
175export function summarize(v: Video): VideoSummary {
176 const ready = v.takes.filter((t) => t.status === 'ready');
177 const used = new Set(v.timeline.map((s) => s.takeId));
178 const usedTakes = ready.filter((t) => used.has(t.id));
179 return {
180 id: v.id,
181 title: v.title,
182 description: v.description,
183 createdAt: v.createdAt,
184 updatedAt: v.updatedAt,
185 duration: timelineDuration(v.timeline),
186 takeCount: ready.length,
187 hasScreen: usedTakes.some((t) => t.tracks.screen),
188 hasCamera: usedTakes.some((t) => t.tracks.camera),
189 thumbnailUrl: v.thumbnail ? `/media/${v.id}/${v.thumbnail.file}?v=${encodeURIComponent(v.thumbnail.updatedAt)}` : null,
190 exportState: !v.export ? 'none' : v.export.signature === exportSignature(v) ? 'current' : 'stale',
191 };
194export async function listVideos(): Promise<VideoSummary[]> {
195 const entries = await fs.readdir(root, { withFileTypes: true });
196 const videos = new Map<string, Video>();
197 for (const e of entries) {
198 if (!e.isDirectory() || !ID_RE.test(e.name)) continue;
199 try {
200 videos.set(e.name, await getVideo(e.name));
201 } catch {
202 // not a video directory
203 }
204 }
205 const lib = await readLibrary();
206 const ordered = lib.order.filter((id) => videos.has(id));
207 const rest = [...videos.keys()].filter((id) => !ordered.includes(id)).sort((a, b) => videos.get(a)!.createdAt.localeCompare(videos.get(b)!.createdAt));
208 return [...ordered, ...rest].map((id) => summarize(videos.get(id)!));
211export function setOrder(order: string[]) {
212 order.forEach(checkId);
213 return withLock('__library__', async () => {
214 await writeJsonAtomic(path.join(root, 'library.json'), { order });
215 });
218export async function trashVideo(id: string) {
219 const src = videoDir(id);
220 if (!(await exists(path.join(src, 'video.json')))) throw new HttpError(404, `Video not found: ${id}`);
221 const trash = path.join(root, '.trash');
222 await fs.mkdir(trash, { recursive: true });
223 await withLock(id, () => fs.rename(src, path.join(trash, `${id}--deleted-${Date.now()}`)));
224 await withLock('__library__', async () => {
225 const lib = await readLibrary();
226 lib.order = lib.order.filter((x) => x !== id);
227 await writeJsonAtomic(path.join(root, 'library.json'), lib);
228 });
↑↓ move↵openescclose