/** * Timestamp mapping helpers — used by both the player and the editor. * * The source video is never re-encoded. All edits live in `recordings.edits_json` * as a ripple-style list of trim ranges. When edits declare an `excluded` range, * playback skips that range — effectively shortening the video. * * Two timelines exist: * - ORIGINAL time: the real video timestamp (0 to recording.durationMs). * Transcript segments, comments, and reactions are all stored in original time. * - EDITED time: the playback-visible timeline after excluded ranges are removed. * * Helpers here convert between the two. Non-excluded trim entries (splits) do * not shift time — they're only UI markers. */ export interface TrimRange { startMs: number; endMs: number; /** If true, this range is skipped during playback. False = split marker. */ excluded: boolean; } export interface BlurBox { id: string; startMs: number; endMs: number; /** Normalized 0-1 coords relative to video dimensions. */ x: number; y: number; w: number; h: number; intensity: number; } export interface ThumbnailSpec { kind: "url" | "frame" | "gif"; value: string; } export interface EditsJson { version: 1; trims: TrimRange[]; blurs: BlurBox[]; thumbnail?: ThumbnailSpec | null; /** Provenance: source recording IDs when this recording was created via stitch-recordings. */ stitchedFrom?: string[]; /** Original countdown-complete boundary after an explicit Rewind pre-roll was prepended. */ rewindOriginalStartMs?: number; } export const DEFAULT_EDITS: EditsJson = { version: 1, trims: [], blurs: [], thumbnail: null, }; /** * Parse `recording.editsJson` (a TEXT column) into an EditsJson object. * Accepts missing fields and returns fully-populated defaults. */ export function parseEdits(raw: string | null | undefined): EditsJson { if (!raw) return { ...DEFAULT_EDITS }; try { const j = JSON.parse(raw); if (!j || typeof j !== "object") return { ...DEFAULT_EDITS }; return { version: 1, trims: Array.isArray(j.trims) ? (j.trims as TrimRange[]).filter(isValidTrim) : [], blurs: Array.isArray(j.blurs) ? (j.blurs as BlurBox[]) : [], thumbnail: j.thumbnail ?? null, ...(Array.isArray(j.stitchedFrom) ? { stitchedFrom: j.stitchedFrom as string[] } : {}), ...(typeof j.rewindOriginalStartMs === "number" && Number.isFinite(j.rewindOriginalStartMs) && j.rewindOriginalStartMs > 0 ? { rewindOriginalStartMs: Math.round(j.rewindOriginalStartMs) } : {}), }; } catch { return { ...DEFAULT_EDITS }; } } function isValidTrim(t: any): t is TrimRange { return ( t && typeof t.startMs === "number" && typeof t.endMs === "number" && t.startMs <= t.endMs ); } export function serializeEdits(edits: EditsJson): string { return JSON.stringify(edits); } /** Return ONLY the excluded ranges, sorted and non-overlapping. */ export function getExcludedRanges(edits: EditsJson): TrimRange[] { return normalizeExcluded(edits.trims.filter((t) => t.excluded)); } /** Merge adjacent/overlapping excluded ranges so downstream logic can rely on a clean list. */ export function normalizeExcluded(ranges: TrimRange[]): TrimRange[] { if (!ranges.length) return []; const sorted = [...ranges] .map((r) => ({ ...r })) .sort((a, b) => a.startMs - b.startMs); const out: TrimRange[] = [sorted[0]]; for (let i = 1; i < sorted.length; i++) { const prev = out[out.length - 1]; const cur = sorted[i]; if (cur.startMs <= prev.endMs) { prev.endMs = Math.max(prev.endMs, cur.endMs); } else { out.push(cur); } } return out; } /** * Map an ORIGINAL timestamp to the EDITED timeline. Timestamps that fall * inside an excluded range snap to the start of that range on the edited timeline. */ export function originalToEdited(originalMs: number, edits: EditsJson): number { let skipped = 0; for (const range of getExcludedRanges(edits)) { if (originalMs <= range.startMs) break; const overlap = Math.min(originalMs, range.endMs) - range.startMs; skipped += Math.max(0, overlap); } return Math.max(0, originalMs - skipped); } /** * Map an EDITED timestamp back to the ORIGINAL timeline. Used when the player * reports an "edited" time and we need to know what real second of the video * we're at (e.g., to show the transcript, to seek the underlying