/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / server / store.ts
227 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};
90export type FullConfig = typeof DEFAULT_CONFIG;
92export async function getConfig(): Promise<FullConfig> {
93 return { ...DEFAULT_CONFIG, ...(await readJson<Partial<FullConfig>>(path.join(root, 'config.json'), {})) };
96export function updateConfig(patch: Partial<FullConfig>): Promise<FullConfig> {
97 return withLock('__config__', async () => {
98 const cfg = await getConfig();
99 for (const k of Object.keys(DEFAULT_CONFIG) as (keyof FullConfig)[]) {
100 if (patch[k] !== undefined) (cfg as unknown as Record<string, unknown>)[k] = patch[k];
101 }
102 await writeJsonAtomic(path.join(root, 'config.json'), cfg, 0o600);
103 return cfg;
104 });
107// ---- videos ----
109export async function getVideo(id: string): Promise<Video> {
110 const v = await readJson<Video | null>(path.join(videoDir(id), 'video.json'), null);
111 if (!v) throw new HttpError(404, `Video not found: ${id}`);
112 return v;
115export function updateVideo(id: string, fn: (v: Video) => void | Promise<void>): Promise<Video> {
116 return withLock(id, async () => {
117 const v = await getVideo(id);
118 await fn(v);
119 v.updatedAt = new Date().toISOString();
120 await writeJsonAtomic(path.join(videoDir(id), 'video.json'), v);
121 return v;
122 });
125function newVideoId(title: string) {
126 const d = new Date();
127 const pad = (n: number) => String(n).padStart(2, '0');
128 const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
129 const slug = slugify(title).slice(0, 40);
130 return [stamp, slug, randomId(4)].filter(Boolean).join('-');
133export function slugify(s: string) {
134 return s
135 .toLowerCase()
136 .normalize('NFKD')
137 .replace(/[^a-z0-9]+/g, '-')
138 .replace(/^-+|-+$/g, '');
141export async function createVideo(input: { title?: string; description?: string; notes?: string }): Promise<Video> {
142 const cfg = await getConfig();
143 const now = new Date().toISOString();
144 const title = (input.title ?? '').trim() || 'Untitled video';
145 const v: Video = {
146 id: newVideoId(title),
147 title,
148 description: input.description ?? '',
149 notes: input.notes ?? '',
150 createdAt: now,
151 updatedAt: now,
152 takes: [],
153 timeline: [],
154 layout: { mode: 'pip', corner: 'br', size: 0.22, shape: 'rounded' },
155 output: { height: cfg.defaultOutputHeight, normalizeAudio: true },
156 thumbnail: null,
157 export: null,
158 };
159 await fs.mkdir(path.join(videoDir(v.id), 'takes'), { recursive: true });
160 await writeJsonAtomic(path.join(videoDir(v.id), 'video.json'), v);
161 await withLock('__library__', async () => {
162 const lib = await readLibrary();
163 lib.order.push(v.id);
164 await writeJsonAtomic(path.join(root, 'library.json'), lib);
165 });
166 return v;
169async function readLibrary() {
170 return readJson<{ order: string[] }>(path.join(root, 'library.json'), { order: [] });
173export function summarize(v: Video): VideoSummary {
174 const ready = v.takes.filter((t) => t.status === 'ready');
175 const used = new Set(v.timeline.map((s) => s.takeId));
176 const usedTakes = ready.filter((t) => used.has(t.id));
177 return {
178 id: v.id,
179 title: v.title,
180 description: v.description,
181 createdAt: v.createdAt,
182 updatedAt: v.updatedAt,
183 duration: timelineDuration(v.timeline),
184 takeCount: ready.length,
185 hasScreen: usedTakes.some((t) => t.tracks.screen),
186 hasCamera: usedTakes.some((t) => t.tracks.camera),
187 thumbnailUrl: v.thumbnail ? `/media/${v.id}/${v.thumbnail.file}?v=${encodeURIComponent(v.thumbnail.updatedAt)}` : null,
188 exportState: !v.export ? 'none' : v.export.signature === exportSignature(v) ? 'current' : 'stale',
189 };
192export async function listVideos(): Promise<VideoSummary[]> {
193 const entries = await fs.readdir(root, { withFileTypes: true });
194 const videos = new Map<string, Video>();
195 for (const e of entries) {
196 if (!e.isDirectory() || !ID_RE.test(e.name)) continue;
197 try {
198 videos.set(e.name, await getVideo(e.name));
199 } catch {
200 // not a video directory
201 }
202 }
203 const lib = await readLibrary();
204 const ordered = lib.order.filter((id) => videos.has(id));
205 const rest = [...videos.keys()].filter((id) => !ordered.includes(id)).sort((a, b) => videos.get(a)!.createdAt.localeCompare(videos.get(b)!.createdAt));
206 return [...ordered, ...rest].map((id) => summarize(videos.get(id)!));
209export function setOrder(order: string[]) {
210 order.forEach(checkId);
211 return withLock('__library__', async () => {
212 await writeJsonAtomic(path.join(root, 'library.json'), { order });
213 });
216export async function trashVideo(id: string) {
217 const src = videoDir(id);
218 if (!(await exists(path.join(src, 'video.json')))) throw new HttpError(404, `Video not found: ${id}`);
219 const trash = path.join(root, '.trash');
220 await fs.mkdir(trash, { recursive: true });
221 await withLock(id, () => fs.rename(src, path.join(trash, `${id}--deleted-${Date.now()}`)));
222 await withLock('__library__', async () => {
223 const lib = await readLibrary();
224 lib.order = lib.order.filter((x) => x !== id);
225 await writeJsonAtomic(path.join(root, 'library.json'), lib);
226 });
↑↓ move↵openescclose