Grabby-Voice-Classic / src /lib /server /clipSessionStore.ts
moonlantern1's picture
Use recorded durations for clip rendering
513b862
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);
}