| import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; |
| import path from 'path'; |
|
|
| export type StoredClip = { |
| step: number; |
| takeId: number; |
| mediaType: 'video' | 'audio'; |
| ext: 'webm' | 'mp4' | 'mov'; |
| filePath: string; |
| size: number; |
| durationSeconds?: number; |
| }; |
|
|
| type ParsedClipFile = { |
| step: number; |
| takeId: number; |
| mediaType: 'video' | 'audio'; |
| ext: StoredClip['ext']; |
| }; |
|
|
| const SESSION_ROOT = path.join(process.cwd(), '.local-review-data', 'clip-sessions'); |
| const MAX_CLIP_BYTES = 120 * 1024 * 1024; |
|
|
| function safeSessionId(sessionId: string) { |
| if (!/^[a-zA-Z0-9_-]{8,80}$/.test(sessionId)) return null; |
| return sessionId; |
| } |
|
|
| function safeExt(ext: string): StoredClip['ext'] { |
| const normalized = ext.toLowerCase(); |
| if (normalized === 'mp4' || normalized === 'mov' || normalized === 'webm') return normalized; |
| return 'webm'; |
| } |
|
|
| function sessionDir(sessionId: string) { |
| const safe = safeSessionId(sessionId); |
| if (!safe) return null; |
|
|
| const absolute = path.resolve(SESSION_ROOT, safe); |
| const root = path.resolve(SESSION_ROOT); |
| if (absolute !== root && !absolute.startsWith(`${root}${path.sep}`)) return null; |
| return absolute; |
| } |
|
|
| function safeTakeId(takeId: number) { |
| return Number.isSafeInteger(takeId) && takeId >= 0 ? takeId : 0; |
| } |
|
|
| function safeDurationSeconds(durationSeconds: number | undefined) { |
| if (!Number.isFinite(durationSeconds) || durationSeconds === undefined) return undefined; |
| if (durationSeconds <= 0 || durationSeconds > 600) return undefined; |
| return durationSeconds; |
| } |
|
|
| function metadataPathFor(filePath: string) { |
| return `${filePath}.json`; |
| } |
|
|
| async function readClipMetadata(filePath: string) { |
| try { |
| const raw = await readFile(metadataPathFor(filePath), 'utf8'); |
| const parsed = JSON.parse(raw) as { durationSeconds?: unknown }; |
| const durationSeconds = |
| typeof parsed.durationSeconds === 'number' ? parsed.durationSeconds : undefined; |
| return { durationSeconds: safeDurationSeconds(durationSeconds) }; |
| } catch { |
| return {}; |
| } |
| } |
|
|
| function parseClipFileName(name: string): ParsedClipFile | null { |
| const withTake = /^(\d+)-(video|audio)-(\d+)\.(webm|mp4|mov)$/.exec(name); |
| if (withTake) { |
| return { |
| step: Number(withTake[1]), |
| mediaType: withTake[2] as ParsedClipFile['mediaType'], |
| takeId: Number(withTake[3]), |
| ext: withTake[4] as StoredClip['ext'], |
| }; |
| } |
|
|
| const legacy = /^(\d+)-(video|audio)\.(webm|mp4|mov)$/.exec(name); |
| if (legacy) { |
| return { |
| step: Number(legacy[1]), |
| mediaType: legacy[2] as ParsedClipFile['mediaType'], |
| takeId: 0, |
| ext: legacy[3] as StoredClip['ext'], |
| }; |
| } |
|
|
| return null; |
| } |
|
|
| async function removeOlderTakes(dir: string, input: { step: number; mediaType: 'video' | 'audio'; takeId: number }) { |
| const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); |
| await Promise.all( |
| entries.map(async (entry) => { |
| if (!entry.isFile()) return; |
| const parsed = parseClipFileName(entry.name); |
| if (!parsed) return; |
| if ( |
| parsed.step === input.step && |
| parsed.mediaType === input.mediaType && |
| parsed.takeId < input.takeId |
| ) { |
| const filePath = path.join(dir, entry.name); |
| await Promise.all([ |
| rm(filePath, { force: true }).catch(() => undefined), |
| rm(metadataPathFor(filePath), { force: true }).catch(() => undefined), |
| ]); |
| } |
| }), |
| ); |
| } |
|
|
| export async function saveSessionClip(input: { |
| sessionId: string; |
| step: number; |
| takeId: number; |
| mediaType: 'video' | 'audio'; |
| ext: string; |
| file: File; |
| durationSeconds?: number; |
| }) { |
| const dir = sessionDir(input.sessionId); |
| if (!dir) throw new Error('Invalid upload session.'); |
| if (!Number.isInteger(input.step) || input.step < 1 || input.step > 20) { |
| throw new Error('Invalid clip step.'); |
| } |
| if (input.file.size <= 0) throw new Error('Clip was empty.'); |
| if (input.file.size > MAX_CLIP_BYTES) { |
| throw new Error('Clip is too large. Please record shorter shots.'); |
| } |
|
|
| const ext = safeExt(input.ext); |
| const takeId = safeTakeId(input.takeId); |
| await mkdir(dir, { recursive: true }); |
|
|
| const filePath = path.join(dir, `${input.step}-${input.mediaType}-${takeId}.${ext}`); |
| const bytes = Buffer.from(await input.file.arrayBuffer()); |
| const durationSeconds = safeDurationSeconds(input.durationSeconds); |
| await writeFile(filePath, bytes); |
| await writeFile( |
| metadataPathFor(filePath), |
| JSON.stringify({ |
| step: input.step, |
| takeId, |
| mediaType: input.mediaType, |
| durationSeconds, |
| }), |
| ); |
| await removeOlderTakes(dir, { |
| step: input.step, |
| mediaType: input.mediaType, |
| takeId, |
| }); |
|
|
| return { |
| sessionId: input.sessionId, |
| step: input.step, |
| takeId, |
| mediaType: input.mediaType, |
| ext, |
| size: bytes.length, |
| durationSeconds, |
| }; |
| } |
|
|
| export async function listSessionClips(sessionId: string) { |
| const dir = sessionDir(sessionId); |
| if (!dir) throw new Error('Invalid upload session.'); |
|
|
| const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); |
| const clipsBySlot = new Map<string, StoredClip & { mtimeMs: number }>(); |
|
|
| for (const entry of entries) { |
| if (!entry.isFile()) continue; |
| const parsed = parseClipFileName(entry.name); |
| if (!parsed) continue; |
|
|
| const filePath = path.join(dir, entry.name); |
| const fileStat = await stat(filePath).catch(() => null); |
| if (!fileStat) continue; |
| const metadata = await readClipMetadata(filePath); |
|
|
| const slotKey = `${parsed.step}:${parsed.mediaType}`; |
| const existing = clipsBySlot.get(slotKey); |
| if ( |
| existing && |
| (existing.takeId > parsed.takeId || |
| (existing.takeId === parsed.takeId && existing.mtimeMs >= fileStat.mtimeMs)) |
| ) { |
| continue; |
| } |
|
|
| clipsBySlot.set(slotKey, { |
| step: parsed.step, |
| takeId: parsed.takeId, |
| mediaType: parsed.mediaType, |
| ext: parsed.ext, |
| filePath, |
| size: fileStat.size, |
| durationSeconds: metadata.durationSeconds, |
| mtimeMs: fileStat.mtimeMs, |
| }); |
| } |
|
|
| return Array.from(clipsBySlot.values()) |
| .map(({ mtimeMs: _mtimeMs, ...clip }) => clip) |
| .sort((a, b) => a.step - b.step); |
| } |
|
|
| export async function cleanupSessionClips(sessionId: string) { |
| const dir = sessionDir(sessionId); |
| if (!dir) return; |
| await rm(dir, { recursive: true, force: true }).catch(() => undefined); |
| } |
|
|