From 3f0fe8850a53d337ebf5c97353f200e6a762baac Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 00:44:55 +0530 Subject: [PATCH 01/36] feat: give split operations a scene title, source and group Split ops existed in the type union but nothing used them. They become scene boundaries: an optional title names the scene, source records whether a person, the AI or shot detection placed it, and groupId lets a batch of ops (e.g. applied suggestions) undo as one step. The route rejects a split at or past the end of the video, since that would make an empty scene; a split at 0 is allowed and only names the first scene. Co-Authored-By: Claude Opus 5.5 --- client/src/types/edit-operation.ts | 46 ++++++++++++------- .../src/lib/validation/edit-operation.test.ts | 20 ++++++++ server/src/lib/validation/edit-operation.ts | 6 +++ server/src/routes/operations.ts | 6 ++- server/src/types/edit-operation.ts | 46 ++++++++++++------- 5 files changed, 91 insertions(+), 33 deletions(-) diff --git a/client/src/types/edit-operation.ts b/client/src/types/edit-operation.ts index ecdfa4c..150cf9d 100644 --- a/client/src/types/edit-operation.ts +++ b/client/src/types/edit-operation.ts @@ -1,32 +1,46 @@ +/** + * Fields every operation shares. `groupId` ties together ops created by one + * user action (e.g. applying a batch of suggested scenes) so undo/redo can + * treat them as a single step. + */ +type OperationBase = { + id: string; + createdAt: number; + groupId?: string; +}; + +/** Where a scene boundary came from -- shown in the UI, never changes behavior. */ +export type SplitSource = "manual" | "ai" | "shot"; + export type EditOperation = - | { - id: string; + | (OperationBase & { type: "cut"; start: number; end: number; reason?: string; - createdAt: number; - } - | { - id: string; + }) + | (OperationBase & { type: "trim"; start: number; end: number; - createdAt: number; - } - | { - id: string; + }) + | (OperationBase & { + /** + * A scene boundary in source time. Splits don't change what's + * rendered; they divide the episode into named scenes that cuts, + * cards and transitions attach to. + */ type: "split"; timestamp: number; - createdAt: number; - } - | { - id: string; + title?: string; + source?: SplitSource; + }) + | (OperationBase & { type: "caption"; text: string; start: number; end: number; - createdAt: number; - }; + }); export type CutOperation = Extract; +export type SplitOperation = Extract; diff --git a/server/src/lib/validation/edit-operation.test.ts b/server/src/lib/validation/edit-operation.test.ts index bcfd8c3..167da60 100644 --- a/server/src/lib/validation/edit-operation.test.ts +++ b/server/src/lib/validation/edit-operation.test.ts @@ -77,4 +77,24 @@ describe("editOperationSchema", () => { const result = editOperationSchema.safeParse({ id: "1", type: "cut", start: 0, timestamp: 5, createdAt: 0 }); expect(result.success).toBe(false); }); + + it("accepts a split with a scene title, source and group", () => { + expect( + editOperationSchema.safeParse({ + id: "1", + type: "split", + timestamp: 42.5, + title: "Pricing", + source: "ai", + groupId: "g1", + createdAt: 0, + }).success + ).toBe(true); + }); + + it("rejects an over-long scene title and an unknown split source", () => { + const base = { id: "1", type: "split", timestamp: 3, createdAt: 0 }; + expect(editOperationSchema.safeParse({ ...base, title: "x".repeat(81) }).success).toBe(false); + expect(editOperationSchema.safeParse({ ...base, source: "robot" }).success).toBe(false); + }); }); diff --git a/server/src/lib/validation/edit-operation.ts b/server/src/lib/validation/edit-operation.ts index 8978889..05b3060 100644 --- a/server/src/lib/validation/edit-operation.ts +++ b/server/src/lib/validation/edit-operation.ts @@ -15,6 +15,7 @@ export const editOperationSchema = z.discriminatedUnion("type", [ end: z.number().nonnegative(), reason: z.string().max(500).optional(), createdAt: z.number(), + groupId: z.string().min(1).max(100).optional(), }), z.object({ id: z.string().min(1), @@ -22,12 +23,16 @@ export const editOperationSchema = z.discriminatedUnion("type", [ start: z.number().nonnegative(), end: z.number().nonnegative(), createdAt: z.number(), + groupId: z.string().min(1).max(100).optional(), }), z.object({ id: z.string().min(1), type: z.literal("split"), timestamp: z.number().nonnegative(), + title: z.string().trim().max(80).optional(), + source: z.enum(["manual", "ai", "shot"]).optional(), createdAt: z.number(), + groupId: z.string().min(1).max(100).optional(), }), z.object({ id: z.string().min(1), @@ -36,5 +41,6 @@ export const editOperationSchema = z.discriminatedUnion("type", [ start: z.number().nonnegative(), end: z.number().nonnegative(), createdAt: z.number(), + groupId: z.string().min(1).max(100).optional(), }), ]); diff --git a/server/src/routes/operations.ts b/server/src/routes/operations.ts index ed7002b..1fa4850 100644 --- a/server/src/routes/operations.ts +++ b/server/src/routes/operations.ts @@ -25,10 +25,14 @@ operationsRoute.post("/:id/operations", async (c) => { return errorResponse(c, "INVALID_OPERATION", "end must be after start.", 400); } - const project = await prisma.project.findUnique({ where: { id: projectId }, select: { id: true } }); + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { id: true, duration: true } }); if (!project) { return errorResponse(c, "NOT_FOUND", "Project not found.", 404); } + // A split at 0 only names the first scene; one at or past the end would make an empty scene. + if (op.type === "split" && project.duration && op.timestamp >= project.duration) { + return errorResponse(c, "INVALID_OPERATION", "Split must fall inside the video.", 400); + } // Client generates the id (needed so undo/redo can address the exact row); // upsert so a redo that restores a previously-undone op is idempotent. diff --git a/server/src/types/edit-operation.ts b/server/src/types/edit-operation.ts index ecdfa4c..150cf9d 100644 --- a/server/src/types/edit-operation.ts +++ b/server/src/types/edit-operation.ts @@ -1,32 +1,46 @@ +/** + * Fields every operation shares. `groupId` ties together ops created by one + * user action (e.g. applying a batch of suggested scenes) so undo/redo can + * treat them as a single step. + */ +type OperationBase = { + id: string; + createdAt: number; + groupId?: string; +}; + +/** Where a scene boundary came from -- shown in the UI, never changes behavior. */ +export type SplitSource = "manual" | "ai" | "shot"; + export type EditOperation = - | { - id: string; + | (OperationBase & { type: "cut"; start: number; end: number; reason?: string; - createdAt: number; - } - | { - id: string; + }) + | (OperationBase & { type: "trim"; start: number; end: number; - createdAt: number; - } - | { - id: string; + }) + | (OperationBase & { + /** + * A scene boundary in source time. Splits don't change what's + * rendered; they divide the episode into named scenes that cuts, + * cards and transitions attach to. + */ type: "split"; timestamp: number; - createdAt: number; - } - | { - id: string; + title?: string; + source?: SplitSource; + }) + | (OperationBase & { type: "caption"; text: string; start: number; end: number; - createdAt: number; - }; + }); export type CutOperation = Extract; +export type SplitOperation = Extract; From 04232702099999a2ec2879e4ecd516ba1c868c8f Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 00:44:55 +0530 Subject: [PATCH 02/36] feat(client): undo history as steps, not single operations Undo used to pop the last operation, so removing 40 filler words took 40 undos, and restoring a cut or renaming anything couldn't be undone at all. History is now a stack of steps, each recording the ops it added and removed: bulk actions (filler words, long pauses, cutting a speaker) commit one step, restores are undoable, and replacing an op -- how a scene gets renamed, since ops are never mutated -- swaps back on undo. After a reload the steps are rebuilt from the persisted ops, grouping by groupId. Co-Authored-By: Claude Opus 5.5 --- client/src/components/editor/AiToolsPanel.tsx | 16 +- .../src/components/transcript/SpeakersBar.tsx | 15 +- client/src/stores/timeline-store.ts | 195 ++++++++++++------ 3 files changed, 147 insertions(+), 79 deletions(-) diff --git a/client/src/components/editor/AiToolsPanel.tsx b/client/src/components/editor/AiToolsPanel.tsx index 22ce033..8dc368b 100644 --- a/client/src/components/editor/AiToolsPanel.tsx +++ b/client/src/components/editor/AiToolsPanel.tsx @@ -19,7 +19,7 @@ export function AiToolsPanel() { const setSilenceGaps = useTranscriptStore((s) => s.setSilenceGaps); const projectId = useProjectStore((s) => s.id); - const addCut = useTimelineStore((s) => s.addCut); + const addCuts = useTimelineStore((s) => s.addCuts); const allWords = useMemo( () => transcript?.segments.flatMap((seg) => seg.words) ?? [], @@ -43,11 +43,11 @@ export function AiToolsPanel() { const handleRemoveFillerWords = () => { const fillerWordIdSet = new Set(fillerWordIds); - for (const word of allWords) { - if (fillerWordIdSet.has(word.id)) { - addCut(padCutStart(word.start, allWords), word.end, "filler word"); - } - } + addCuts( + allWords + .filter((word) => fillerWordIdSet.has(word.id)) + .map((word) => ({ start: padCutStart(word.start, allWords), end: word.end, reason: "filler word" })) + ); setFillerWordIds([]); }; @@ -58,9 +58,7 @@ export function AiToolsPanel() { const handleRemoveSilences = () => { if (!silenceGaps) return; - for (const gap of silenceGaps) { - addCut(gap.start, gap.end, "long pause"); - } + addCuts(silenceGaps.map((gap) => ({ start: gap.start, end: gap.end, reason: "long pause" }))); setSilenceGaps(null); }; diff --git a/client/src/components/transcript/SpeakersBar.tsx b/client/src/components/transcript/SpeakersBar.tsx index b9beeea..1f86405 100644 --- a/client/src/components/transcript/SpeakersBar.tsx +++ b/client/src/components/transcript/SpeakersBar.tsx @@ -112,7 +112,7 @@ export function SpeakersBar() { const transcript = useTranscriptStore((s) => s.transcript); const setTranscript = useTranscriptStore((s) => s.setTranscript); const operations = useTimelineStore((s) => s.operations); - const addCut = useTimelineStore((s) => s.addCut); + const addCuts = useTimelineStore((s) => s.addCuts); const removeOperations = useTimelineStore((s) => s.removeOperations); const [job, setJob] = useState(null); @@ -197,11 +197,14 @@ export function SpeakersBar() { const segmentsOf = (name: string) => transcript.segments.filter((s) => s.speaker === name && s.words.length > 0); const cutAll = (name: string) => { - for (const segment of segmentsOf(name)) { - if (isFullyCut(segment.words, cuts)) continue; - const { start, end } = wordSpanBounds(segment.words, { start: segment.start, end: segment.end }); - addCut(padCutStart(start, allWords), end, `speaker: ${name}`); - } + addCuts( + segmentsOf(name) + .filter((segment) => !isFullyCut(segment.words, cuts)) + .map((segment) => { + const { start, end } = wordSpanBounds(segment.words, { start: segment.start, end: segment.end }); + return { start: padCutStart(start, allWords), end, reason: `speaker: ${name}` }; + }) + ); }; const restoreAll = (name: string) => { diff --git a/client/src/stores/timeline-store.ts b/client/src/stores/timeline-store.ts index 1607386..fd90fe3 100644 --- a/client/src/stores/timeline-store.ts +++ b/client/src/stores/timeline-store.ts @@ -1,19 +1,34 @@ import { create } from "zustand"; -import type { CutOperation, EditOperation } from "@/types/edit-operation"; +import type { CutOperation, EditOperation, SplitOperation } from "@/types/edit-operation"; + +/** + * One undoable step: the operations it added and the ones it removed. + * Most steps only add (a cut, a split); replacing an op -- e.g. renaming a + * scene, since ops are never mutated in place -- removes the old version + * and adds the new one, and undo swaps them back. + */ +type HistoryEntry = { added: EditOperation[]; removed: EditOperation[] }; type TimelineStore = { projectId: string | null; operations: EditOperation[]; - redoStack: EditOperation[]; + undoStack: HistoryEntry[]; + redoStack: HistoryEntry[]; addCut: (start: number, end: number, reason?: string) => void; + /** Several cuts as one undo step (e.g. "Remove all filler words"). */ + addCuts: (cuts: { start: number; end: number; reason?: string }[]) => void; + /** Adds a scene boundary at a source time; callers snap it off words first (lib/timeline/scenes.ts). */ + addSplit: (timestamp: number, title?: string) => void; + /** Adds several operations as one undo step (they share a groupId). */ + addOperations: (ops: EditOperation[]) => void; + /** Swaps one operation for a new version of it, as one undo step. */ + replaceOperation: (operationId: string, next: EditOperation) => void; /** * Removes specific operations by id, regardless of where they sit in - * history -- unlike undo/redo (which only ever act on the top of the - * stack), this lets a user restore one particular cut word/segment/ - * sentence directly. Clears the redo stack, same as addCut: once history - * has been edited out of order like this, replaying old undo()s no - * longer has a well-defined meaning. + * history -- this lets a user restore one particular cut word/segment/ + * sentence directly. It's its own undo step, so an accidental restore + * can be undone too. */ removeOperations: (operationIds: string[]) => void; undo: () => void; @@ -41,60 +56,112 @@ function deleteOperation(projectId: string | null, operationId: string) { ); } -export const useTimelineStore = create((set, get) => ({ - projectId: null, - operations: [], - redoStack: [], - - addCut: (start, end, reason) => { - if (end <= start) return; - const op: CutOperation = { - id: crypto.randomUUID(), - type: "cut", - start, - end, - reason, - createdAt: Date.now(), - }; - set((s) => ({ operations: [...s.operations, op], redoStack: [] })); - persistOperation(get().projectId, op); - }, - - removeOperations: (operationIds) => { - const idSet = new Set(operationIds); - const { operations, projectId } = get(); - const toRemove = operations.filter((op) => idSet.has(op.id)); - if (toRemove.length === 0) return; - set((s) => ({ operations: s.operations.filter((op) => !idSet.has(op.id)), redoStack: [] })); - for (const op of toRemove) deleteOperation(projectId, op.id); - }, - - undo: () => { - const { operations, projectId } = get(); - if (operations.length === 0) return; - const last = operations[operations.length - 1]; - set((s) => ({ - operations: s.operations.slice(0, -1), - redoStack: [...s.redoStack, last], - })); - deleteOperation(projectId, last.id); - }, - - redo: () => { - const { redoStack, projectId } = get(); - if (redoStack.length === 0) return; - const op = redoStack[redoStack.length - 1]; - set((s) => ({ - operations: [...s.operations, op], - redoStack: s.redoStack.slice(0, -1), - })); - persistOperation(projectId, op); - }, - - hydrate: (projectId, operations) => set({ projectId, operations, redoStack: [] }), - - reset: () => set({ projectId: null, operations: [], redoStack: [] }), - - cuts: () => - get().operations.filter((op): op is CutOperation => op.type === "cut"), -})); +function makeCut(start: number, end: number, reason?: string): CutOperation { + return { id: crypto.randomUUID(), type: "cut", start, end, reason, createdAt: Date.now() }; +} + +/** + * Rebuilds undo history from persisted ops, so undo still works after a + * reload: each op is its own step, except ops sharing a groupId, which + * undo together. Replacements can't be reconstructed (the old version is + * gone), so after a reload undoing a rename removes the scene boundary -- + * the same thing undoing the original split would have done. + */ +function historyFromOperations(operations: EditOperation[]): HistoryEntry[] { + const entries: HistoryEntry[] = []; + for (const op of operations) { + const last = entries[entries.length - 1]; + if (op.groupId && last?.added[0]?.groupId === op.groupId) { + last.added.push(op); + } else { + entries.push({ added: [op], removed: [] }); + } + } + return entries; +} + +export const useTimelineStore = create((set, get) => { + /** Applies a step's changes (or their inverse) to state and the server. */ + const apply = (added: EditOperation[], removed: EditOperation[]) => { + const removedIds = new Set(removed.map((op) => op.id)); + set((s) => ({ operations: [...s.operations.filter((op) => !removedIds.has(op.id)), ...added] })); + const { projectId } = get(); + for (const op of removed) deleteOperation(projectId, op.id); + for (const op of added) persistOperation(projectId, op); + }; + + const commit = (entry: HistoryEntry) => { + if (entry.added.length === 0 && entry.removed.length === 0) return; + apply(entry.added, entry.removed); + set((s) => ({ undoStack: [...s.undoStack, entry], redoStack: [] })); + }; + + return { + projectId: null, + operations: [], + undoStack: [], + redoStack: [], + + addCut: (start, end, reason) => { + if (end <= start) return; + commit({ added: [makeCut(start, end, reason)], removed: [] }); + }, + + addCuts: (cuts) => { + const valid = cuts.filter((c) => c.end > c.start); + if (valid.length === 1) get().addCut(valid[0].start, valid[0].end, valid[0].reason); + else get().addOperations(valid.map((c) => makeCut(c.start, c.end, c.reason))); + }, + + addSplit: (timestamp, title) => { + const op: SplitOperation = { + id: crypto.randomUUID(), + type: "split", + timestamp, + source: "manual", + createdAt: Date.now(), + ...(title ? { title } : {}), + }; + commit({ added: [op], removed: [] }); + }, + + addOperations: (ops) => { + if (ops.length === 0) return; + const groupId = crypto.randomUUID(); + commit({ added: ops.map((op) => ({ ...op, groupId })), removed: [] }); + }, + + replaceOperation: (operationId, next) => { + const current = get().operations.find((op) => op.id === operationId); + if (!current) return; + commit({ added: [next], removed: [current] }); + }, + + removeOperations: (operationIds) => { + const idSet = new Set(operationIds); + const toRemove = get().operations.filter((op) => idSet.has(op.id)); + commit({ added: [], removed: toRemove }); + }, + + undo: () => { + const entry = get().undoStack.at(-1); + if (!entry) return; + apply(entry.removed, entry.added); + set((s) => ({ undoStack: s.undoStack.slice(0, -1), redoStack: [...s.redoStack, entry] })); + }, + + redo: () => { + const entry = get().redoStack.at(-1); + if (!entry) return; + apply(entry.added, entry.removed); + set((s) => ({ redoStack: s.redoStack.slice(0, -1), undoStack: [...s.undoStack, entry] })); + }, + + hydrate: (projectId, operations) => + set({ projectId, operations, undoStack: historyFromOperations(operations), redoStack: [] }), + + reset: () => set({ projectId: null, operations: [], undoStack: [], redoStack: [] }), + + cuts: () => get().operations.filter((op): op is CutOperation => op.type === "cut"), + }; +}); From 551a55fd2dc01857cef17168f651bc4d7935aebd Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 00:44:55 +0530 Subject: [PATCH 03/36] feat(client): derive scenes from split operations Scenes are computed, never stored: the ranges between split points, with their position on the edited timeline so a fully cut scene collapses to zero width but stays listed for restoring. New splits snap to the middle of the nearest gap between words, so a boundary -- and later a card or transition placed on it -- never lands inside a word. Co-Authored-By: Claude Opus 5.5 --- client/src/lib/timeline/scenes.test.ts | 108 +++++++++++++++++++ client/src/lib/timeline/scenes.ts | 142 +++++++++++++++++++++++++ client/src/lib/timeline/useScenes.ts | 41 +++++++ 3 files changed, 291 insertions(+) create mode 100644 client/src/lib/timeline/scenes.test.ts create mode 100644 client/src/lib/timeline/scenes.ts create mode 100644 client/src/lib/timeline/useScenes.ts diff --git a/client/src/lib/timeline/scenes.test.ts b/client/src/lib/timeline/scenes.test.ts new file mode 100644 index 0000000..3c1e84b --- /dev/null +++ b/client/src/lib/timeline/scenes.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { buildScenes, gapBefore, sceneAt, snapToWordGap, sortedSplits } from "@/lib/timeline/scenes"; +import { computePlayableRanges } from "@/lib/timeline/cuts"; +import type { CutOperation, EditOperation } from "@/types/edit-operation"; + +const split = (id: string, timestamp: number, extra: Partial = {}): EditOperation => + ({ id, type: "split", timestamp, createdAt: 0, ...extra }) as EditOperation; +const cut = (id: string, start: number, end: number): CutOperation => ({ id, type: "cut", start, end, createdAt: 0 }); + +describe("buildScenes", () => { + it("is one scene covering the whole video when there are no splits", () => { + const ranges = computePlayableRanges(100, []); + expect(buildScenes(100, [], ranges)).toEqual([ + { + id: "scene-start", + splitId: null, + index: 0, + title: "Scene 1", + source: null, + start: 0, + end: 100, + editedStart: 0, + editedEnd: 100, + }, + ]); + }); + + it("divides at splits in time order and keeps their titles", () => { + const ops = [split("b", 60, { title: "Pricing" } as Partial), split("a", 20)]; + const scenes = buildScenes(100, ops, computePlayableRanges(100, [])); + expect(scenes.map((s) => [s.title, s.start, s.end, s.splitId])).toEqual([ + ["Scene 1", 0, 20, null], + ["Scene 2", 20, 60, "a"], + ["Pricing", 60, 100, "b"], + ]); + }); + + it("places scenes on the edited timeline, with a fully cut scene collapsing to zero width", () => { + const cuts = [cut("c1", 0, 10), cut("c2", 20, 60)]; + const ops: EditOperation[] = [...cuts, split("a", 20), split("b", 60)]; + const scenes = buildScenes(100, ops, computePlayableRanges(100, cuts)); + expect(scenes.map((s) => [s.editedStart, s.editedEnd])).toEqual([ + [0, 10], + [10, 10], + [10, 50], + ]); + }); + + it("uses a split at 0 only to name the first scene", () => { + const ops = [split("open", 0, { title: "Intro" } as Partial), split("a", 30)]; + const scenes = buildScenes(100, ops, computePlayableRanges(100, [])); + expect(scenes.map((s) => [s.title, s.start, s.splitId])).toEqual([ + ["Intro", 0, "open"], + ["Scene 2", 30, "a"], + ]); + }); + + it("ignores splits at or outside the video's ends", () => { + const ops = [split("a", 0), split("b", 100), split("c", 150)]; + expect(buildScenes(100, ops, computePlayableRanges(100, []))).toHaveLength(1); + }); +}); + +describe("sortedSplits", () => { + it("drops a near-duplicate split, keeping the earlier-created one", () => { + const ops = [ + split("late", 30.02, { createdAt: 5 } as Partial), + split("early", 30, { createdAt: 1 } as Partial), + ]; + expect(sortedSplits(ops, 100).map((s) => s.id)).toEqual(["early"]); + }); +}); + +describe("sceneAt", () => { + it("finds the scene containing a source time", () => { + const scenes = buildScenes(100, [split("a", 40)], computePlayableRanges(100, [])); + expect(sceneAt(scenes, 10)?.index).toBe(0); + expect(sceneAt(scenes, 40)?.index).toBe(1); + expect(sceneAt(scenes, 100)?.index).toBe(1); + }); +}); + +describe("snapToWordGap", () => { + const words = [ + { start: 1, end: 2 }, + { start: 2.4, end: 3 }, + { start: 4, end: 5 }, + ]; + + it("leaves a time that is already between words alone", () => { + expect(snapToWordGap(3.5, words)).toBe(3.5); + }); + + it("moves a time inside a word to the middle of the nearer gap", () => { + expect(snapToWordGap(2.5, words)).toBeCloseTo(2.2); // closer to the start of "2.4-3" + expect(snapToWordGap(2.9, words)).toBeCloseTo(3.5); // closer to its end + }); + + it("returns the time unchanged with no words", () => { + expect(snapToWordGap(7, [])).toBe(7); + }); +}); + +describe("gapBefore", () => { + it("is the midpoint between the previous word's end and this word's start", () => { + expect(gapBefore(4, [{ start: 1, end: 3 }, { start: 4, end: 5 }])).toBe(3.5); + }); +}); diff --git a/client/src/lib/timeline/scenes.ts b/client/src/lib/timeline/scenes.ts new file mode 100644 index 0000000..383e8f7 --- /dev/null +++ b/client/src/lib/timeline/scenes.ts @@ -0,0 +1,142 @@ +import type { EditOperation, SplitOperation, SplitSource } from "@/types/edit-operation"; +import type { PlayableRange } from "@/types/timeline"; +import { sourceTimeToEditedTime } from "@/lib/timeline/cuts"; + +/** Two splits closer than this are the same boundary (double-click, AI + manual). */ +const SPLIT_DEDUPE_SECONDS = 0.05; + +/** + * A stretch of the source between two scene boundaries (split operations). + * Scenes are derived, never stored: the split ops are the source of truth, + * so undo/redo and persistence need nothing new. + */ +export type Scene = { + /** The split op that starts this scene, or "scene-start" for the first one. */ + id: string; + /** null for the first scene, which starts at 0 without a split. */ + splitId: string | null; + index: number; + title: string; + source: SplitSource | null; + /** Source time. */ + start: number; + end: number; + /** Position on the edited (cuts-removed) timeline. */ + editedStart: number; + editedEnd: number; +}; + +export function defaultSceneTitle(index: number): string { + return `Scene ${index + 1}`; +} + +/** Split ops inside (0, duration), sorted, with near-duplicates dropped (the earliest-created wins). */ +export function sortedSplits(operations: EditOperation[], duration: number): SplitOperation[] { + const splits = operations + .filter((op): op is SplitOperation => op.type === "split") + .filter((op) => op.timestamp > 0 && op.timestamp < duration) + .sort((a, b) => a.timestamp - b.timestamp || a.createdAt - b.createdAt); + + const result: SplitOperation[] = []; + for (const split of splits) { + const last = result[result.length - 1]; + if (last && split.timestamp - last.timestamp < SPLIT_DEDUPE_SECONDS) continue; + result.push(split); + } + return result; +} + +/** + * Divides [0, duration] into scenes at every split. A scene whose source + * range was cut entirely is still returned (editedStart === editedEnd), so + * the scene list can offer to restore it. + */ +export function buildScenes( + duration: number, + operations: EditOperation[], + playableRanges: PlayableRange[] +): Scene[] { + if (duration <= 0) return []; + const splits = sortedSplits(operations, duration); + const bounds = [0, ...splits.map((s) => s.timestamp), duration]; + const opening = openingSplit(operations); + + return bounds.slice(0, -1).map((start, i) => { + const end = bounds[i + 1]; + const split = i === 0 ? opening : splits[i - 1]; + return { + id: split?.id ?? "scene-start", + splitId: split?.id ?? null, + index: i, + title: split?.title?.trim() || defaultSceneTitle(i), + source: split?.source ?? null, + start, + end, + editedStart: sourceTimeToEditedTime(start, playableRanges), + editedEnd: sourceTimeToEditedTime(end, playableRanges), + }; + }); +} + +/** + * A split at exactly 0 isn't a boundary -- the first scene always starts + * there -- it only carries the first scene's title, so that scene can be + * renamed like any other. The latest one wins. + */ +function openingSplit(operations: EditOperation[]): SplitOperation | null { + const openings = operations.filter((op): op is SplitOperation => op.type === "split" && op.timestamp === 0); + return openings.sort((a, b) => a.createdAt - b.createdAt).at(-1) ?? null; +} + +/** The scene containing this source time (the last scene for t >= duration). */ +export function sceneAt(scenes: Scene[], sourceTime: number): Scene | null { + for (const scene of scenes) { + if (sourceTime < scene.end) return scene; + } + return scenes[scenes.length - 1] ?? null; +} + +/** + * Moves a time off any word it falls inside, to the middle of the nearest + * gap between words, so a scene boundary never splits a word -- which + * would later put a transition or card in the middle of speech. Times + * already in a gap (or with no words at all) come back unchanged. + */ +export function snapToWordGap(time: number, words: { start: number; end: number }[]): number { + if (words.length === 0) return time; + const sorted = [...words].sort((a, b) => a.start - b.start); + + const inside = sorted.findIndex((w) => time > w.start && time < w.end); + if (inside === -1) return time; + + const word = sorted[inside]; + const previousEnd = inside > 0 ? sorted[inside - 1].end : 0; + const nextStart = inside < sorted.length - 1 ? sorted[inside + 1].start : word.end; + const before = (previousEnd + word.start) / 2; + const after = (word.end + nextStart) / 2; + return time - word.start <= word.end - time ? before : after; +} + +/** + * Scene block colors, cycled by index. Full class names so Tailwind's + * scanner keeps them (same approach as lib/transcript/speakers.ts). + */ +const SCENE_COLORS = [ + { block: "border-sky-500 bg-sky-500/20", dot: "bg-sky-500" }, + { block: "border-amber-500 bg-amber-500/20", dot: "bg-amber-500" }, + { block: "border-emerald-500 bg-emerald-500/20", dot: "bg-emerald-500" }, + { block: "border-violet-500 bg-violet-500/20", dot: "bg-violet-500" }, +]; + +export function sceneColor(index: number): { block: string; dot: string } { + return SCENE_COLORS[index % SCENE_COLORS.length]; +} + +/** Midpoint of the gap just before a word (where "split before this sentence" lands). */ +export function gapBefore(wordStart: number, words: { start: number; end: number }[]): number { + let previousEnd = 0; + for (const w of words) { + if (w.end <= wordStart && w.end > previousEnd) previousEnd = w.end; + } + return (previousEnd + wordStart) / 2; +} diff --git a/client/src/lib/timeline/useScenes.ts b/client/src/lib/timeline/useScenes.ts new file mode 100644 index 0000000..001138b --- /dev/null +++ b/client/src/lib/timeline/useScenes.ts @@ -0,0 +1,41 @@ +import { useCallback, useMemo } from "react"; +import { usePlayerStore } from "@/stores/player-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useTranscriptStore } from "@/stores/transcript-store"; +import { computePlayableRanges } from "@/lib/timeline/cuts"; +import { buildScenes, snapToWordGap, type Scene } from "@/lib/timeline/scenes"; +import type { CutOperation } from "@/types/edit-operation"; + +/** Minimum distance from an existing boundary (or either end) for a new split. */ +const MIN_SCENE_SECONDS = 0.5; + +/** The project's scenes plus the split action, derived from the timeline store. */ +export function useScenes(): { + scenes: Scene[]; + /** Splits at a source time (snapped off words); returns false if too close to an existing boundary. */ + splitAt: (sourceTime: number) => boolean; +} { + const duration = usePlayerStore((s) => s.duration); + const operations = useTimelineStore((s) => s.operations); + const addSplit = useTimelineStore((s) => s.addSplit); + const transcript = useTranscriptStore((s) => s.transcript); + + const scenes = useMemo(() => { + const cuts = operations.filter((op): op is CutOperation => op.type === "cut"); + return buildScenes(duration, operations, computePlayableRanges(duration, cuts)); + }, [duration, operations]); + + const splitAt = useCallback( + (sourceTime: number) => { + const words = transcript?.segments.flatMap((s) => s.words) ?? []; + const t = snapToWordGap(sourceTime, words); + const boundaries = [0, duration, ...scenes.map((s) => s.start)]; + if (boundaries.some((b) => Math.abs(b - t) < MIN_SCENE_SECONDS)) return false; + addSplit(t); + return true; + }, + [transcript, duration, scenes, addSplit] + ); + + return { scenes, splitAt }; +} From e270cda8bd73eb1a3e634cb90f69f3868d3f1cbb Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 00:44:55 +0530 Subject: [PATCH 04/36] feat(client): Scenes panel, scene strip and split shortcuts Split at the playhead (S or the timeline button) or before any sentence in the transcript. Scenes show as named blocks above the timeline and as markers inline in the transcript; the Scenes panel renames, jumps to, cuts, restores and merges them. Cmd/Ctrl+Z and Shift+Cmd/Ctrl+Z now undo and redo from anywhere outside a text field. Co-Authored-By: Claude Opus 5.5 --- client/src/components/editor/EditorLayout.tsx | 2 + .../src/components/editor/EditorSidebar.tsx | 7 +- client/src/components/editor/ScenesPanel.tsx | 144 ++++++++++++++++++ .../components/editor/useEditorShortcuts.ts | 33 ++++ client/src/components/timeline/Timeline.tsx | 52 ++++++- .../components/transcript/TranscriptPanel.tsx | 91 +++++++---- 6 files changed, 297 insertions(+), 32 deletions(-) create mode 100644 client/src/components/editor/ScenesPanel.tsx create mode 100644 client/src/components/editor/useEditorShortcuts.ts diff --git a/client/src/components/editor/EditorLayout.tsx b/client/src/components/editor/EditorLayout.tsx index ca5c5fe..c61a4e8 100644 --- a/client/src/components/editor/EditorLayout.tsx +++ b/client/src/components/editor/EditorLayout.tsx @@ -10,6 +10,7 @@ import { ExportButton } from "@/components/editor/ExportButton"; import { ProjectNameField } from "@/components/editor/ProjectNameField"; import { GithubLink } from "@/components/GithubLink"; import { usePlayerStore } from "@/stores/player-store"; +import { useEditorShortcuts } from "@/components/editor/useEditorShortcuts"; export function EditorLayout({ videoUrl }: { videoUrl: string }) { // Matches the preview's aspect box to the source video's own aspect ratio @@ -18,6 +19,7 @@ export function EditorLayout({ videoUrl }: { videoUrl: string }) { // editor for non-16:9 video that the actual ffmpeg export never had, // since export doesn't touch aspect ratio at all. const aspectRatio = usePlayerStore((s) => s.aspectRatio); + useEditorShortcuts(); return (
diff --git a/client/src/components/editor/EditorSidebar.tsx b/client/src/components/editor/EditorSidebar.tsx index 670fcbb..49e6d6e 100644 --- a/client/src/components/editor/EditorSidebar.tsx +++ b/client/src/components/editor/EditorSidebar.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { AudioLines, BookOpenText, Captions, ChevronLeft, Clapperboard, ChevronRight, ImagePlus, SlidersHorizontal, Sliders, Wand2 } from "lucide-react"; +import { AudioLines, BookOpenText, Captions, ChevronLeft, Clapperboard, ChevronRight, Film, ImagePlus, SlidersHorizontal, Sliders, Wand2 } from "lucide-react"; import { AiToolsPanel } from "@/components/editor/AiToolsPanel"; import { FiltersPanel } from "@/components/editor/FiltersPanel"; import { PropertiesPanel } from "@/components/editor/PropertiesPanel"; @@ -10,11 +10,13 @@ import { CaptionsPanel } from "@/components/editor/CaptionsPanel"; import { AudioPanel } from "@/components/editor/AudioPanel"; import { PublishPanel } from "@/components/editor/PublishPanel"; import { ClipsPanel } from "@/components/editor/ClipsPanel"; +import { ScenesPanel } from "@/components/editor/ScenesPanel"; -type PanelId = "captions" | "audio" | "publish" | "clips" | "ai" | "filters" | "properties" | "elements"; +type PanelId = "captions" | "scenes" | "audio" | "publish" | "clips" | "ai" | "filters" | "properties" | "elements"; const TABS: { id: PanelId; label: string; icon: typeof Wand2 }[] = [ { id: "captions", label: "Captions", icon: Captions }, + { id: "scenes", label: "Scenes", icon: Film }, { id: "audio", label: "Audio", icon: AudioLines }, { id: "publish", label: "Publish", icon: BookOpenText }, { id: "clips", label: "Clips", icon: Clapperboard }, @@ -109,6 +111,7 @@ export function EditorSidebar() {
{activePanel === "captions" && } + {activePanel === "scenes" && } {activePanel === "audio" && } {activePanel === "publish" && } {activePanel === "clips" && } diff --git a/client/src/components/editor/ScenesPanel.tsx b/client/src/components/editor/ScenesPanel.tsx new file mode 100644 index 0000000..dde71b7 --- /dev/null +++ b/client/src/components/editor/ScenesPanel.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useMemo } from "react"; +import { Merge, Play, RotateCcw, Scissors, Trash2 } from "lucide-react"; +import { usePlayerStore } from "@/stores/player-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useScenes } from "@/lib/timeline/useScenes"; +import { sceneAt, sceneColor, type Scene } from "@/lib/timeline/scenes"; +import { cutsOverlapping } from "@/lib/timeline/cuts"; +import { formatTimecode } from "@/lib/timeline/format"; +import { Button } from "@/components/ui/button"; +import type { CutOperation } from "@/types/edit-operation"; + +/** + * Lists the episode's scenes -- stretches between split points -- so you + * can name them, jump to them, drop a whole scene, or merge it back into + * the one before. Everything here is ordinary edit operations (splits and + * cuts), so it all goes through undo/redo. + */ +export function ScenesPanel() { + const currentTime = usePlayerStore((s) => s.currentTime); + const seek = usePlayerStore((s) => s.seek); + const operations = useTimelineStore((s) => s.operations); + const addSplit = useTimelineStore((s) => s.addSplit); + const addCut = useTimelineStore((s) => s.addCut); + const replaceOperation = useTimelineStore((s) => s.replaceOperation); + const removeOperations = useTimelineStore((s) => s.removeOperations); + const { scenes, splitAt } = useScenes(); + + const cuts = useMemo(() => operations.filter((op): op is CutOperation => op.type === "cut"), [operations]); + const activeScene = sceneAt(scenes, currentTime); + + const rename = (scene: Scene, title: string) => { + const split = operations.find((op) => op.id === scene.splitId); + if (split?.type === "split") { + replaceOperation(split.id, { ...split, id: crypto.randomUUID(), title, createdAt: Date.now() }); + } else if (scene.index === 0) { + addSplit(0, title); + } + }; + + const restore = (scene: Scene) => removeOperations(cutsOverlapping(scene.start, scene.end, cuts).map((c) => c.id)); + + return ( +
+
+ +

+ Or press S. Splits land between words, never + inside one. +

+
+ + {scenes.length <= 1 ? ( +

+ The whole episode is one scene. Split it at each topic change to name sections and drop whole segments at + once. +

+ ) : ( +
    + {scenes.map((scene) => { + const kept = scene.editedEnd - scene.editedStart; + const fullyCut = kept <= 0; + const partlyCut = !fullyCut && kept < scene.end - scene.start - 0.05; + const active = activeScene?.id === scene.id; + return ( +
  1. +
    + + { + if (e.key === "Enter") e.currentTarget.blur(); + if (e.key === "Escape") { + e.currentTarget.value = scene.title; + e.currentTarget.blur(); + } + }} + onBlur={(e) => { + const title = e.target.value.trim(); + if (title && title !== scene.title) rename(scene, title); + else e.target.value = scene.title; + }} + className={`min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 font-medium hover:border-border focus:border-primary focus:outline-none ${ + fullyCut ? "line-through" : "" + }`} + /> + +
    +
    + + {formatTimecode(scene.start)} · {formatTimecode(kept)} + {partlyCut && (trimmed)} + + + {scene.index > 0 && scene.splitId && ( + + )} + {fullyCut || partlyCut ? ( + + ) : null} + {!fullyCut && ( + + )} + +
    +
  2. + ); + })} +
+ )} +
+ ); +} diff --git a/client/src/components/editor/useEditorShortcuts.ts b/client/src/components/editor/useEditorShortcuts.ts new file mode 100644 index 0000000..25b2331 --- /dev/null +++ b/client/src/components/editor/useEditorShortcuts.ts @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { usePlayerStore } from "@/stores/player-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useScenes } from "@/lib/timeline/useScenes"; + +function isTyping(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName); +} + +/** Editor-wide keys: S splits a scene at the playhead, Cmd/Ctrl+Z undoes, Shift+Cmd/Ctrl+Z redoes. */ +export function useEditorShortcuts() { + const undo = useTimelineStore((s) => s.undo); + const redo = useTimelineStore((s) => s.redo); + const { splitAt } = useScenes(); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (isTyping(e.target)) return; + const mod = e.metaKey || e.ctrlKey; + if (mod && e.key.toLowerCase() === "z") { + e.preventDefault(); + if (e.shiftKey) redo(); + else undo(); + } else if (!mod && !e.altKey && e.key.toLowerCase() === "s") { + e.preventDefault(); + splitAt(usePlayerStore.getState().currentTime); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [undo, redo, splitAt]); +} diff --git a/client/src/components/timeline/Timeline.tsx b/client/src/components/timeline/Timeline.tsx index 2dd5617..5cbbe7a 100644 --- a/client/src/components/timeline/Timeline.tsx +++ b/client/src/components/timeline/Timeline.tsx @@ -16,6 +16,9 @@ import { useWaveform } from "@/lib/timeline/useWaveform"; import { DEFAULT_INTERVAL_SECONDS, MIN_INTERVAL_SECONDS, useThumbnails } from "@/lib/timeline/useThumbnails"; import { Button } from "@/components/ui/button"; import { Waveform } from "@/components/timeline/Waveform"; +import { useScenes } from "@/lib/timeline/useScenes"; +import { sceneColor } from "@/lib/timeline/scenes"; +import { Scissors } from "lucide-react"; import type { CutOperation } from "@/types/edit-operation"; /** Zoom is relative to "fit" (1x = whole timeline visible, no scrolling). */ @@ -47,7 +50,9 @@ export function Timeline() { const operations = useTimelineStore((s) => s.operations); const undo = useTimelineStore((s) => s.undo); const redo = useTimelineStore((s) => s.redo); + const undoStack = useTimelineStore((s) => s.undoStack); const redoStack = useTimelineStore((s) => s.redoStack); + const { scenes, splitAt } = useScenes(); const cuts = useMemo( () => operations.filter((op): op is CutOperation => op.type === "cut"), @@ -129,11 +134,22 @@ export function Timeline() { variant="outline" size="xs" onClick={undo} - disabled={operations.length === 0} + disabled={undoStack.length === 0} + title="Undo (⌘Z)" > Undo - +
@@ -154,6 +170,31 @@ export function Timeline() { className="flex flex-col gap-2" style={{ width: trackWidth > 0 ? `${trackWidth}px` : "100%" }} > + {scenes.length > 1 && editedDuration > 0 && ( +
+ {scenes.map((scene) => { + const width = scene.editedEnd - scene.editedStart; + if (width <= 0) return null; + return ( + + ); + })} +
+ )}
); })} + {scenes.slice(1).map((scene) => ( +
+ ))}
new Set(fillerWordIds), [fillerWordIds]); + + const { scenes, splitAt } = useScenes(); + /** The scene that starts right before each word, keyed by word id -- where the inline scene markers go. */ + const sceneStartsByWordId = useMemo(() => { + const byWord = new Map(); + for (const scene of scenes.slice(1)) { + const word = allWords.find((w) => w.start >= scene.start); + if (word && !byWord.has(word.id)) byWord.set(word.id, scene); + } + return byWord; + }, [scenes, allWords]); const existingSpeakers = useMemo(() => listSpeakers(transcript), [transcript]); if (!transcript) return null; @@ -232,33 +245,46 @@ export function TranscriptPanel() { const matchesSearch = query.trim().length > 0 && word.text.toLowerCase().includes(query.trim().toLowerCase()); + const sceneStart = sceneStartsByWordId.get(word.id); return ( - { - if (cut) { - handleRestore(word.start, word.end); - return; - } - seek(word.start); - handleWordClick(word.id, e); - }} - title={cut ? "Click to restore" : undefined} - className={[ - "cursor-pointer rounded px-0.5 transition-colors", - cut - ? "text-muted-foreground/50 line-through decoration-destructive/70 hover:bg-muted/60" - : "", - selected ? "bg-accent/30" : "", - active && !cut ? "bg-primary/25" : "", - filler && !cut - ? "underline decoration-dotted decoration-muted-foreground underline-offset-4" - : "", - matchesSearch ? "ring-1 ring-accent/70" : "", - ].join(" ")} - > - {word.text}{" "} - + + {sceneStart && ( + + )} + { + if (cut) { + handleRestore(word.start, word.end); + return; + } + seek(word.start); + handleWordClick(word.id, e); + }} + title={cut ? "Click to restore" : undefined} + className={[ + "cursor-pointer rounded px-0.5 transition-colors", + cut + ? "text-muted-foreground/50 line-through decoration-destructive/70 hover:bg-muted/60" + : "", + selected ? "bg-accent/30" : "", + active && !cut ? "bg-primary/25" : "", + filler && !cut + ? "underline decoration-dotted decoration-muted-foreground underline-offset-4" + : "", + matchesSearch ? "ring-1 ring-accent/70" : "", + ].join(" ")} + > + {word.text}{" "} + + ); })} {sentenceFullyCut ? ( @@ -278,6 +304,15 @@ export function TranscriptPanel() { )} + {!sceneStartsByWordId.has(sentence.words[0].id) && ( + + )} ); })} From 3def289baf844e65e688ebfb06d449e6bdc59ecb Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 00:58:47 +0530 Subject: [PATCH 05/36] fix(render): stop dropping footage after every cut on ffmpeg 9 Cuts are joined with a chain of 30ms xfades. On ffmpeg 9 (9.0.2, the current Homebrew ffmpeg-full the Mac app uses), xfade discards the second input's frames before its offset instead of holding them, so every segment after a cut started late: a 2s + 10s join rendered 10.0s long, with the second segment's first ~2s missing, and the loss compounded with each cut. Audio (acrossfade) was unaffected, so exports also drifted out of sync. Pinning each trimmed segment to the source's frame rate with fps= makes the join land where the offset says: the same join renders 11.98s, and a three-segment export's frames match the source at every segment start. The render now always probes the source's rate (probeVideoStream), which also provides the size the logo and caption layout needed. Co-Authored-By: Claude Opus 5.5 --- server/src/lib/ffmpeg/plan.test.ts | 11 ++++++++--- server/src/lib/ffmpeg/plan.ts | 19 ++++++++++++++++++- server/src/lib/ffmpeg/probe.test.ts | 16 ++++++++++++++++ server/src/lib/ffmpeg/probe.ts | 29 +++++++++++++++++++++++++---- server/src/lib/ffmpeg/render-job.ts | 17 ++++++++--------- 5 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 server/src/lib/ffmpeg/probe.test.ts diff --git a/server/src/lib/ffmpeg/plan.test.ts b/server/src/lib/ffmpeg/plan.test.ts index 1bd27e2..156958e 100644 --- a/server/src/lib/ffmpeg/plan.test.ts +++ b/server/src/lib/ffmpeg/plan.test.ts @@ -8,9 +8,14 @@ const baseArgs = { outputPath: "/data/out.mp4", playableRanges: [{ start: 0, end: 10 }], filterId: "none", + frameRate: "30000/1001", }; describe("buildRenderArgs: validation", () => { + it("rejects a frame rate that isn't a plain number or ratio", () => { + expect(() => buildRenderArgs({ ...baseArgs, frameRate: "30,drawtext=x" })).toThrow(/Invalid frame rate/); + }); + it("throws when the entire video has been cut (no playable ranges)", () => { expect(() => buildRenderArgs({ ...baseArgs, playableRanges: [] })).toThrow(/entire video has been cut/); }); @@ -52,7 +57,7 @@ describe("buildRenderArgs: basic structure", () => { it("trims a single range and maps it straight to [outv]/[outa]", () => { const argv = buildRenderArgs(baseArgs); const filterComplex = argv[argv.indexOf("-filter_complex") + 1]; - expect(filterComplex).toContain("[0:v]trim=start=0.000:end=10.000,setpts=PTS-STARTPTS[v0]"); + expect(filterComplex).toContain("[0:v]trim=start=0.000:end=10.000,setpts=PTS-STARTPTS,fps=30000/1001[v0]"); expect(filterComplex).toContain("[0:a]atrim=start=0.000:end=10.000,asetpts=PTS-STARTPTS[a0]"); expect(filterComplex).toContain("[v0]null[outv]"); expect(filterComplex).toContain("[a0]anull[outa]"); @@ -67,8 +72,8 @@ describe("buildRenderArgs: basic structure", () => { ], }); const filterComplex = argv[argv.indexOf("-filter_complex") + 1]; - expect(filterComplex).toContain("[0:v]trim=start=0.000:end=3.000,setpts=PTS-STARTPTS[v0]"); - expect(filterComplex).toContain("[0:v]trim=start=5.000:end=8.000,setpts=PTS-STARTPTS[v1]"); + expect(filterComplex).toContain("[0:v]trim=start=0.000:end=3.000,setpts=PTS-STARTPTS,fps=30000/1001[v0]"); + expect(filterComplex).toContain("[0:v]trim=start=5.000:end=8.000,setpts=PTS-STARTPTS,fps=30000/1001[v1]"); // Both 3s segments comfortably fit the default 0.03s crossfade: offset // is the first segment's own length (3.0) minus that 0.03s. expect(filterComplex).toContain("[v0][v1]xfade=transition=fade:duration=0.030:offset=2.970[outv]"); diff --git a/server/src/lib/ffmpeg/plan.ts b/server/src/lib/ffmpeg/plan.ts index b955e7e..7c4f980 100644 --- a/server/src/lib/ffmpeg/plan.ts +++ b/server/src/lib/ffmpeg/plan.ts @@ -263,6 +263,12 @@ export function buildRenderArgs(args: { * callers pass the output size as videoWidth/videoHeight. */ reframe?: { width: number; height: number; cropX: number }; + /** + * The source's frame rate as ffprobe reports it ("30000/1001", from + * probe.ts#probeVideoStream). Every segment is pinned to it before the + * xfade joins -- see the comment where it's applied. + */ + frameRate: string; }): string[] { const { inputPath, @@ -282,13 +288,24 @@ export function buildRenderArgs(args: { audioFilter, metadataPath, reframe, + frameRate, } = args; assertValidRanges(playableRanges); + if (!/^\d{1,6}(\/\d{1,6})?$/.test(frameRate)) throw new Error(`Invalid frame rate: ${frameRate}`); + // fps= pins each trimmed segment to the source's frame rate. Without it, + // xfade on ffmpeg 9 drops the start of every segment after a join: the + // second input's frames before `offset` are consumed and discarded instead + // of being held until the transition, so each cut silently lost footage + // (measured on 9.0.2: a 2s + 10s join came out 10.0s long, the second + // segment starting ~2s late). With an explicit rate on both inputs the + // join lands where the offset says (11.98s, correct frames). const filterChains: string[] = []; playableRanges.forEach((r, i) => { - filterChains.push(`[0:v]trim=start=${r.start.toFixed(3)}:end=${r.end.toFixed(3)},setpts=PTS-STARTPTS[v${i}]`); + filterChains.push( + `[0:v]trim=start=${r.start.toFixed(3)}:end=${r.end.toFixed(3)},setpts=PTS-STARTPTS,fps=${frameRate}[v${i}]` + ); }); if (playableRanges.length === 1) { diff --git a/server/src/lib/ffmpeg/probe.test.ts b/server/src/lib/ffmpeg/probe.test.ts new file mode 100644 index 0000000..325703d --- /dev/null +++ b/server/src/lib/ffmpeg/probe.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { normalizeFrameRate } from "@/lib/ffmpeg/probe"; + +describe("normalizeFrameRate", () => { + it("keeps ffprobe's rational rate as-is", () => { + expect(normalizeFrameRate("30000/1001")).toBe("30000/1001"); + expect(normalizeFrameRate("25/1")).toBe("25/1"); + }); + + it("falls back to 30 for a missing, zero or implausible rate", () => { + expect(normalizeFrameRate(undefined)).toBe("30"); + expect(normalizeFrameRate("0/0")).toBe("30"); + expect(normalizeFrameRate("90000/1")).toBe("30"); + expect(normalizeFrameRate("30;evil")).toBe("30"); + }); +}); diff --git a/server/src/lib/ffmpeg/probe.ts b/server/src/lib/ffmpeg/probe.ts index 359c6f4..bdcce9d 100644 --- a/server/src/lib/ffmpeg/probe.ts +++ b/server/src/lib/ffmpeg/probe.ts @@ -17,7 +17,18 @@ export class FfprobeNotFoundError extends Error { * The binary defaults to `ffprobe` on PATH, overridable with FFPROBE_PATH -- * mirrors FFMPEG_PATH in ffmpeg/run.ts. */ -export function probeVideoDimensions(inputPath: string): Promise<{ width: number; height: number }> { +export async function probeVideoDimensions(inputPath: string): Promise<{ width: number; height: number }> { + const { width, height } = await probeVideoStream(inputPath); + return { width, height }; +} + +/** + * Width, height and frame rate of the first video stream. The rate is + * ffprobe's r_frame_rate as a rational string ("30000/1001"), passed to + * ffmpeg as-is so every segment of a render runs at exactly the source's + * rate (lib/ffmpeg/plan.ts). + */ +export function probeVideoStream(inputPath: string): Promise<{ width: number; height: number; fps: string }> { const binary = process.env.FFPROBE_PATH || "ffprobe"; const args = [ "-v", @@ -25,7 +36,7 @@ export function probeVideoDimensions(inputPath: string): Promise<{ width: number "-select_streams", "v:0", "-show_entries", - "stream=width,height", + "stream=width,height,r_frame_rate", "-of", "json", inputPath, @@ -44,13 +55,15 @@ export function probeVideoDimensions(inputPath: string): Promise<{ width: number return; } try { - const parsed = JSON.parse(stdout) as { streams?: { width?: number; height?: number }[] }; + const parsed = JSON.parse(stdout) as { + streams?: { width?: number; height?: number; r_frame_rate?: string }[]; + }; const stream = parsed.streams?.[0]; if (!stream?.width || !stream?.height) { reject(new Error(`ffprobe returned no video stream dimensions for ${inputPath}.`)); return; } - resolve({ width: stream.width, height: stream.height }); + resolve({ width: stream.width, height: stream.height, fps: normalizeFrameRate(stream.r_frame_rate) }); } catch { reject(new Error(`Could not parse ffprobe output for ${inputPath}.`)); } @@ -58,6 +71,14 @@ export function probeVideoDimensions(inputPath: string): Promise<{ width: number }); } +/** A usable "num/den" rate, or 30 when ffprobe reports none (or 0/0, as it does for some still images). */ +export function normalizeFrameRate(rate: string | undefined): string { + const match = rate?.match(/^(\d{1,6})\/(\d{1,6})$/); + if (!match || Number(match[1]) === 0 || Number(match[2]) === 0) return "30"; + const fps = Number(match[1]) / Number(match[2]); + return fps > 0 && fps <= 240 ? `${match[1]}/${match[2]}` : "30"; +} + /** * Reads a media file's container duration in seconds via ffprobe -- used to * plan transcription chunks over the extracted audio track. diff --git a/server/src/lib/ffmpeg/render-job.ts b/server/src/lib/ffmpeg/render-job.ts index 82f7152..2ee037f 100644 --- a/server/src/lib/ffmpeg/render-job.ts +++ b/server/src/lib/ffmpeg/render-job.ts @@ -22,7 +22,7 @@ import { } from "@/lib/ffmpeg/audio-filters"; import { parseStoredAudioSettings } from "@/lib/validation/audio-settings"; import { runFfmpeg, runFfmpegCapturingStderr } from "@/lib/ffmpeg/run"; -import { probeVideoDimensions } from "@/lib/ffmpeg/probe"; +import { probeVideoStream } from "@/lib/ffmpeg/probe"; import { generateCaptions, splitCuesForShorts } from "@/lib/captions/generate"; import { toAssKaraoke, toSrt } from "@/lib/captions/format"; import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@/lib/captions/style"; @@ -196,14 +196,12 @@ export async function runRenderJob( const captionStyle = clip ? shortsCaptionStyle(project.captionStyleJson) : options.captionStyle; const useKaraoke = burnInCaptions && captionStyle?.wordHighlight === true; - // Needed to size the logo overlay relative to the frame, and to convert - // force_style's fontSize/margin from percent-of-frame into the literal - // output pixels it actually requires (see ffmpeg/plan.ts) -- skip the - // probe when neither applies. The karaoke .ass path doesn't need this: - // it scales itself via PlayResX/Y instead (see lib/captions/format.ts). - // A clip's output size is known up front; no probe needed. - const needsVideoDimensions = !clipFrame && (useLogo || (burnInCaptions && !useKaraoke)); - const videoDimensions = clipFrame ?? (needsVideoDimensions ? await probeVideoDimensions(inputPath) : undefined); + // Every video render needs the source's frame rate (see ffmpeg/plan.ts). + // The same probe gives its size, which sizes the logo overlay and + // converts force_style's percent-of-frame font size/margin into output + // pixels. A clip's output size is known up front, so it's used instead. + const sourceStream = isVideo ? await probeVideoStream(inputPath) : undefined; + const videoDimensions = clipFrame ?? sourceStream; if (burnInCaptions) { if (!project.transcript) throw new Error("Captions were requested but this project has no transcript."); @@ -268,6 +266,7 @@ export async function runRenderJob( audioFilter, metadataPath, reframe: clip && clipFrame ? { ...clipFrame, cropX: clip.cropX } : undefined, + frameRate: sourceStream!.fps, }) : buildAudioOnlyRenderArgs({ inputPath, outputPath, playableRanges, audioFilter, format, metadataPath }); await runFfmpeg(args); From 036b493f07f26b4735eb585d91ec0818ba2ea0da Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 01:01:51 +0530 Subject: [PATCH 06/36] fix(render): pad segments so a short one can't end the export early Each trimmed segment is a whole number of frames, so it can come out up to a frame shorter than the length the xfade offsets assume. When the stream joined so far runs out before its next join's transition ends, ffmpeg 9 ends that join there and every segment after it is lost -- measured: a 5s + 10s + 2s chain rendered 14.9s with the last segment missing. Every segment but the last now gets 0.1s of cloned frames (tpad). xfade drops the first input's frames after the transition, so the padding never reaches the output: a four-segment export now renders 15.88s video against 15.91s audio (within a frame), and the same three-segment chain renders all 16.95s. Co-Authored-By: Claude Opus 5.5 --- server/src/lib/ffmpeg/plan.test.ts | 4 +++- server/src/lib/ffmpeg/plan.ts | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/server/src/lib/ffmpeg/plan.test.ts b/server/src/lib/ffmpeg/plan.test.ts index 156958e..bfbd447 100644 --- a/server/src/lib/ffmpeg/plan.test.ts +++ b/server/src/lib/ffmpeg/plan.test.ts @@ -72,7 +72,9 @@ describe("buildRenderArgs: basic structure", () => { ], }); const filterComplex = argv[argv.indexOf("-filter_complex") + 1]; - expect(filterComplex).toContain("[0:v]trim=start=0.000:end=3.000,setpts=PTS-STARTPTS,fps=30000/1001[v0]"); + expect(filterComplex).toContain( + "[0:v]trim=start=0.000:end=3.000,setpts=PTS-STARTPTS,fps=30000/1001,tpad=stop_mode=clone:stop_duration=0.1[v0]" + ); expect(filterComplex).toContain("[0:v]trim=start=5.000:end=8.000,setpts=PTS-STARTPTS,fps=30000/1001[v1]"); // Both 3s segments comfortably fit the default 0.03s crossfade: offset // is the first segment's own length (3.0) minus that 0.03s. diff --git a/server/src/lib/ffmpeg/plan.ts b/server/src/lib/ffmpeg/plan.ts index 7c4f980..132af5f 100644 --- a/server/src/lib/ffmpeg/plan.ts +++ b/server/src/lib/ffmpeg/plan.ts @@ -30,6 +30,9 @@ import type { VideoProperties } from "@/types/video-properties"; */ const CUT_CROSSFADE_SECONDS = 0.03; +/** Cloned frames after each segment but the last; see buildRenderArgs. */ +const SEGMENT_TAIL_PAD = "tpad=stop_mode=clone:stop_duration=0.1"; + /** Percent of frame width/height, not raw pixels -- see lib/video/logo.ts. */ function clampPaddingPercent(v: number): number { return Math.min(LOGO_PADDING_MAX_PERCENT, Math.max(LOGO_PADDING_MIN_PERCENT, v)); @@ -301,10 +304,19 @@ export function buildRenderArgs(args: { // (measured on 9.0.2: a 2s + 10s join came out 10.0s long, the second // segment starting ~2s late). With an explicit rate on both inputs the // join lands where the offset says (11.98s, correct frames). + // + // tpad= then pads every segment but the last with a few cloned frames. A + // segment is a whole number of frames, so it can end up to a frame short + // of the length the offsets assume; when the stream built so far runs out + // before its join's transition finishes, ffmpeg 9 ends the join there and + // everything after it is lost (measured: a 5s + 10s + 2s chain came out + // 14.9s, the last segment gone). xfade discards the first input's frames + // after the transition, so the padding never reaches the output. const filterChains: string[] = []; playableRanges.forEach((r, i) => { + const pad = i < playableRanges.length - 1 ? `,${SEGMENT_TAIL_PAD}` : ""; filterChains.push( - `[0:v]trim=start=${r.start.toFixed(3)}:end=${r.end.toFixed(3)},setpts=PTS-STARTPTS,fps=${frameRate}[v${i}]` + `[0:v]trim=start=${r.start.toFixed(3)}:end=${r.end.toFixed(3)},setpts=PTS-STARTPTS,fps=${frameRate}${pad}[v${i}]` ); }); From c69354fa9e60de34203f753cfeb9bd5d976a3d17 Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 01:25:07 +0530 Subject: [PATCH 07/36] feat: add a card operation for title cards A card is a full-screen title card inserted into the program before the content at its source time: a scene start, 0 for an intro, the duration for an outro. It carries a template (title, chapter, quote, outro), a title and optional subtitle, 1-10s on screen and a solid background. Card text is burned in through a generated ASS file, so the schema refuses braces, backslashes and control characters -- they'd be read as override tags or break the event line. The route rejects a card placed past the end of the video. Co-Authored-By: Claude Opus 5.5 --- client/src/types/edit-operation.ts | 25 +++++++++++++++ .../src/lib/validation/edit-operation.test.ts | 31 ++++++++++++++++++ server/src/lib/validation/edit-operation.ts | 32 +++++++++++++++++++ server/src/routes/operations.ts | 4 +++ server/src/types/edit-operation.ts | 25 +++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/client/src/types/edit-operation.ts b/client/src/types/edit-operation.ts index 150cf9d..62dd558 100644 --- a/client/src/types/edit-operation.ts +++ b/client/src/types/edit-operation.ts @@ -35,6 +35,23 @@ export type EditOperation = title?: string; source?: SplitSource; }) + | (OperationBase & { + /** + * A full-screen title card inserted into the program -- time that + * isn't in the source. `at` is the source time it plays before: + * usually a scene boundary, 0 for an intro, or the source duration + * for an outro. See lib/timeline/program.ts for placement. + */ + type: "card"; + at: number; + /** Seconds on screen. */ + duration: number; + template: CardTemplate; + title: string; + subtitle?: string; + /** '#RRGGBB' */ + background: string; + }) | (OperationBase & { type: "caption"; text: string; @@ -42,5 +59,13 @@ export type EditOperation = end: number; }); +export const CARD_TEMPLATES = ["title", "chapter", "quote", "outro"] as const; +export type CardTemplate = (typeof CARD_TEMPLATES)[number]; +export const CARD_MIN_SECONDS = 1; +export const CARD_MAX_SECONDS = 10; +export const CARD_TITLE_MAX = 80; +export const CARD_SUBTITLE_MAX = 120; + export type CutOperation = Extract; export type SplitOperation = Extract; +export type CardOperation = Extract; diff --git a/server/src/lib/validation/edit-operation.test.ts b/server/src/lib/validation/edit-operation.test.ts index 167da60..5de8ceb 100644 --- a/server/src/lib/validation/edit-operation.test.ts +++ b/server/src/lib/validation/edit-operation.test.ts @@ -97,4 +97,35 @@ describe("editOperationSchema", () => { expect(editOperationSchema.safeParse({ ...base, title: "x".repeat(81) }).success).toBe(false); expect(editOperationSchema.safeParse({ ...base, source: "robot" }).success).toBe(false); }); + + describe("card", () => { + const card = { + id: "c1", + type: "card", + at: 30, + duration: 3, + template: "chapter", + title: "Part 2: Pricing", + subtitle: "What it really costs", + background: "#101820", + createdAt: 0, + }; + + it("accepts a valid card", () => { + expect(editOperationSchema.safeParse(card).success).toBe(true); + }); + + it("rejects out-of-range durations, unknown templates and bad colors", () => { + expect(editOperationSchema.safeParse({ ...card, duration: 0.5 }).success).toBe(false); + expect(editOperationSchema.safeParse({ ...card, duration: 11 }).success).toBe(false); + expect(editOperationSchema.safeParse({ ...card, template: "spinning" }).success).toBe(false); + expect(editOperationSchema.safeParse({ ...card, background: "red" }).success).toBe(false); + }); + + it("rejects an empty title and text that would be read as ASS override tags", () => { + expect(editOperationSchema.safeParse({ ...card, title: " " }).success).toBe(false); + expect(editOperationSchema.safeParse({ ...card, title: "{\\fs200}Big" }).success).toBe(false); + expect(editOperationSchema.safeParse({ ...card, subtitle: "two\nlines" }).success).toBe(false); + }); + }); }); diff --git a/server/src/lib/validation/edit-operation.ts b/server/src/lib/validation/edit-operation.ts index 05b3060..df1db89 100644 --- a/server/src/lib/validation/edit-operation.ts +++ b/server/src/lib/validation/edit-operation.ts @@ -1,4 +1,24 @@ import { z } from "zod"; +import { + CARD_MAX_SECONDS, + CARD_MIN_SECONDS, + CARD_SUBTITLE_MAX, + CARD_TEMPLATES, + CARD_TITLE_MAX, +} from "@/types/edit-operation"; + +/** + * Card text is burned in through a generated ASS file: braces and + * backslashes would be read as override tags, and control characters or + * newlines would break the event line. The client strips these before + * sending; the server refuses anything that slips through. + */ +const cardText = (max: number) => + z + .string() + .trim() + .max(max) + .refine((s) => !/[{}\\\u0000-\u001f\u007f]/.test(s), "Card text can't contain braces, backslashes or line breaks."); /** * Mirrors the EditOperation union in types/edit-operation.ts. The client @@ -34,6 +54,18 @@ export const editOperationSchema = z.discriminatedUnion("type", [ createdAt: z.number(), groupId: z.string().min(1).max(100).optional(), }), + z.object({ + id: z.string().min(1), + type: z.literal("card"), + at: z.number().nonnegative(), + duration: z.number().min(CARD_MIN_SECONDS).max(CARD_MAX_SECONDS), + template: z.enum(CARD_TEMPLATES), + title: cardText(CARD_TITLE_MAX).pipe(z.string().min(1)), + subtitle: cardText(CARD_SUBTITLE_MAX).optional(), + background: z.string().regex(/^#[0-9a-fA-F]{6}$/), + createdAt: z.number(), + groupId: z.string().min(1).max(100).optional(), + }), z.object({ id: z.string().min(1), type: z.literal("caption"), diff --git a/server/src/routes/operations.ts b/server/src/routes/operations.ts index 1fa4850..b42e8f8 100644 --- a/server/src/routes/operations.ts +++ b/server/src/routes/operations.ts @@ -33,6 +33,10 @@ operationsRoute.post("/:id/operations", async (c) => { if (op.type === "split" && project.duration && op.timestamp >= project.duration) { return errorResponse(c, "INVALID_OPERATION", "Split must fall inside the video.", 400); } + // A card at the duration is an outro; past it there's nothing to play before. + if (op.type === "card" && project.duration && op.at > project.duration) { + return errorResponse(c, "INVALID_OPERATION", "Card must be placed inside the video.", 400); + } // Client generates the id (needed so undo/redo can address the exact row); // upsert so a redo that restores a previously-undone op is idempotent. diff --git a/server/src/types/edit-operation.ts b/server/src/types/edit-operation.ts index 150cf9d..62dd558 100644 --- a/server/src/types/edit-operation.ts +++ b/server/src/types/edit-operation.ts @@ -35,6 +35,23 @@ export type EditOperation = title?: string; source?: SplitSource; }) + | (OperationBase & { + /** + * A full-screen title card inserted into the program -- time that + * isn't in the source. `at` is the source time it plays before: + * usually a scene boundary, 0 for an intro, or the source duration + * for an outro. See lib/timeline/program.ts for placement. + */ + type: "card"; + at: number; + /** Seconds on screen. */ + duration: number; + template: CardTemplate; + title: string; + subtitle?: string; + /** '#RRGGBB' */ + background: string; + }) | (OperationBase & { type: "caption"; text: string; @@ -42,5 +59,13 @@ export type EditOperation = end: number; }); +export const CARD_TEMPLATES = ["title", "chapter", "quote", "outro"] as const; +export type CardTemplate = (typeof CARD_TEMPLATES)[number]; +export const CARD_MIN_SECONDS = 1; +export const CARD_MAX_SECONDS = 10; +export const CARD_TITLE_MAX = 80; +export const CARD_SUBTITLE_MAX = 120; + export type CutOperation = Extract; export type SplitOperation = Extract; +export type CardOperation = Extract; From b5fd6a4691c196fe0cba6a7c6f668d31a2dee307 Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 01:25:07 +0530 Subject: [PATCH 08/36] feat: program timeline model for title cards Cards add time that isn't in the source, so edited time (source minus cuts) is no longer what plays. program.ts places each card on the edited timeline and maps edited time to program time (edited plus cards), so everything that already works in edited time -- captions, chapters -- shifts into program time without knowing about cards, and cuts and cards compose. A card whose whole scene is cut goes with it, rather than announcing the next scene; intro and outro cards always stay. With no cards the program is exactly the playable ranges, so nothing changes for existing projects. Duplicated across client and server like cuts.ts. Co-Authored-By: Claude Opus 5.5 --- client/src/lib/timeline/program.test.ts | 127 +++++++++++++++++ client/src/lib/timeline/program.ts | 178 ++++++++++++++++++++++++ server/src/lib/timeline/program.test.ts | 127 +++++++++++++++++ server/src/lib/timeline/program.ts | 178 ++++++++++++++++++++++++ 4 files changed, 610 insertions(+) create mode 100644 client/src/lib/timeline/program.test.ts create mode 100644 client/src/lib/timeline/program.ts create mode 100644 server/src/lib/timeline/program.test.ts create mode 100644 server/src/lib/timeline/program.ts diff --git a/client/src/lib/timeline/program.test.ts b/client/src/lib/timeline/program.test.ts new file mode 100644 index 0000000..5f0b449 --- /dev/null +++ b/client/src/lib/timeline/program.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { + buildProgramItems, + cardStartTimes, + cardsDuration, + editedRangeToProgram, + editedToProgramTime, + locateProgramTime, + placeCards, +} from "@/lib/timeline/program"; +import { computePlayableRanges } from "@/lib/timeline/cuts"; +import type { CardOperation, CutOperation, EditOperation } from "@/types/edit-operation"; + +const card = (id: string, at: number, duration = 3, createdAt = 0): CardOperation => ({ + id, + type: "card", + at, + duration, + template: "title", + title: id, + background: "#000000", + createdAt, +}); +const cut = (start: number, end: number): CutOperation => ({ id: `cut-${start}`, type: "cut", start, end, createdAt: 0 }); +const split = (timestamp: number): EditOperation => ({ id: `split-${timestamp}`, type: "split", timestamp, createdAt: 0 }); + +function program(ops: EditOperation[], duration = 100) { + const ranges = computePlayableRanges( + duration, + ops.filter((op): op is CutOperation => op.type === "cut") + ); + const slots = placeCards(ops, ranges, duration); + return { ranges, slots, items: buildProgramItems(ranges, slots) }; +} + +describe("program without cards", () => { + it("is exactly the playable ranges, with no time shift", () => { + const { ranges, slots, items } = program([cut(10, 20), split(50)]); + expect(slots).toEqual([]); + expect(items).toEqual(ranges.map((r) => ({ kind: "source", ...r }))); + expect(editedToProgramTime(42, slots)).toBe(42); + }); +}); + +describe("placeCards", () => { + it("places an intro, a mid-episode card and an outro in play order", () => { + const { slots } = program([card("outro", 100), card("mid", 40), card("intro", 0)]); + expect(slots.map((s) => [s.card.id, s.editedAt, s.resumeAt])).toEqual([ + ["intro", 0, 0], + ["mid", 40, 40], + ["outro", 100, 100], + ]); + }); + + it("moves a card whose moment was cut to the next kept content", () => { + const { slots } = program([cut(35, 45), card("mid", 40)]); + expect(slots[0]).toMatchObject({ editedAt: 35, resumeAt: 45 }); + }); + + it("drops a card whose whole scene was cut, but keeps intro and outro", () => { + const { slots } = program([split(40), split(60), cut(40, 60), card("gone", 40), card("intro", 0), card("outro", 100)]); + expect(slots.map((s) => s.card.id)).toEqual(["intro", "outro"]); + }); + + it("keeps cards at the same moment in creation order", () => { + const { slots } = program([card("second", 40, 3, 2), card("first", 40, 3, 1)]); + expect(slots.map((s) => s.card.id)).toEqual(["first", "second"]); + }); +}); + +describe("buildProgramItems", () => { + it("splits the source range where a card is inserted", () => { + const { items } = program([card("intro", 0, 2), card("mid", 40, 3), cut(80, 100), card("outro", 100, 4)]); + expect(items).toEqual([ + { kind: "card", card: expect.objectContaining({ id: "intro" }) }, + { kind: "source", start: 0, end: 40 }, + { kind: "card", card: expect.objectContaining({ id: "mid" }) }, + { kind: "source", start: 40, end: 80 }, + { kind: "card", card: expect.objectContaining({ id: "outro" }) }, + ]); + }); +}); + +describe("time mapping", () => { + const { slots } = program([card("intro", 0, 2), card("mid", 40, 3)]); + + it("shifts edited time by every card before it", () => { + expect(cardsDuration(slots)).toBe(5); + expect(editedToProgramTime(10, slots)).toBe(12); + expect(editedToProgramTime(50, slots)).toBe(55); + }); + + it("puts content at a card's moment after the card, and chapter markers at its start", () => { + expect(editedToProgramTime(40, slots)).toBe(45); + expect(editedToProgramTime(40, slots, { includeCardsAtPoint: false })).toBe(42); + expect(editedToProgramTime(0, slots, { includeCardsAtPoint: false })).toBe(0); + }); + + it("locates program time inside a card or back on the edited timeline", () => { + expect(locateProgramTime(1, slots)).toMatchObject({ card: { card: { id: "intro" } }, cardOffset: 1 }); + expect(locateProgramTime(12, slots)).toMatchObject({ card: null, editedTime: 10 }); + expect(locateProgramTime(43.5, slots)).toMatchObject({ card: { card: { id: "mid" } }, cardOffset: 1.5 }); + expect(locateProgramTime(55, slots)).toMatchObject({ card: null, editedTime: 50 }); + }); + + it("round-trips edited -> program -> edited outside cards", () => { + for (const t of [0.5, 20, 39.9, 40.1, 99]) { + expect(locateProgramTime(editedToProgramTime(t, slots), slots).editedTime).toBeCloseTo(t); + } + }); +}); + +describe("editedRangeToProgram and cardStartTimes", () => { + const { slots, items } = program([card("intro", 0, 2), card("mid", 40, 3)]); + + it("keeps a span that ends where a card starts from running over the card", () => { + expect(editedRangeToProgram(38, 40, slots)).toEqual({ start: 40, end: 42 }); + expect(editedRangeToProgram(40, 41, slots)).toEqual({ start: 45, end: 46 }); + }); + + it("lists each card's start on the program timeline", () => { + expect(cardStartTimes(items).map((c) => [c.card.id, c.start])).toEqual([ + ["intro", 0], + ["mid", 42], + ]); + }); +}); diff --git a/client/src/lib/timeline/program.ts b/client/src/lib/timeline/program.ts new file mode 100644 index 0000000..2219864 --- /dev/null +++ b/client/src/lib/timeline/program.ts @@ -0,0 +1,178 @@ +import type { CardOperation, EditOperation, SplitOperation } from "@/types/edit-operation"; +import type { PlayableRange } from "@/types/timeline"; +import { nextPlayableTime, sourceTimeToEditedTime } from "@/lib/timeline/cuts"; + +/** + * The "program" is what actually plays: the kept source ranges with title + * cards inserted between them. Cards add time that isn't in the source, so + * three clocks exist: + * + * source time -- the original video (transcript, cuts, splits, cards' `at`) + * edited time -- source with cuts removed (lib/timeline/cuts.ts) + * program time -- edited time with cards inserted (what renders) + * + * Everything that already works in edited time (captions, chapters) shifts + * into program time through editedToProgramTime, so cuts and cards compose + * without either knowing about the other. Duplicated, not imported, across + * the client/server boundary like cuts.ts -- keep both copies identical. + */ + +const EPSILON = 0.001; + +/** A card placed on the edited timeline. */ +export type CardSlot = { + card: CardOperation; + /** Edited time the card plays at (before the source content there). */ + editedAt: number; + /** Source time playback resumes at once the card has played. */ + resumeAt: number; +}; + +export type ProgramItem = + | { kind: "source"; start: number; end: number } + | { kind: "card"; card: CardOperation }; + +export function cardOperations(operations: EditOperation[]): CardOperation[] { + return operations.filter((op): op is CardOperation => op.type === "card"); +} + +function splitTimes(operations: EditOperation[]): number[] { + return operations + .filter((op): op is SplitOperation => op.type === "split" && op.timestamp > 0) + .map((op) => op.timestamp) + .sort((a, b) => a - b); +} + +/** + * Places each card on the edited timeline, in play order. + * + * A card plays before the content at its `at` time; if that moment was + * cut, before the next content that wasn't. A card belongs to the scene + * starting at `at`, so when that whole scene is cut its card goes too -- + * otherwise cutting "Part 2" would leave its title card announcing Part 3. + * Intro (at 0) and outro (at the end) cards always stay. + */ +export function placeCards( + operations: EditOperation[], + ranges: PlayableRange[], + sourceDuration: number +): CardSlot[] { + const splits = splitTimes(operations); + const editedDuration = ranges.reduce((sum, r) => sum + (r.end - r.start), 0); + + const slots: CardSlot[] = []; + for (const card of cardOperations(operations)) { + const isOutro = card.at >= sourceDuration - EPSILON; + const isIntro = card.at <= EPSILON; + if (!isIntro && !isOutro) { + const sceneEnd = splits.find((t) => t > card.at + EPSILON) ?? sourceDuration; + const sceneHasContent = ranges.some((r) => r.start < sceneEnd && r.end > card.at); + if (!sceneHasContent) continue; + } + const resumeAt = isOutro ? null : nextPlayableTime(card.at, ranges); + slots.push( + resumeAt === null + ? { card, editedAt: editedDuration, resumeAt: sourceDuration } + : { card, editedAt: sourceTimeToEditedTime(resumeAt, ranges), resumeAt } + ); + } + return slots.sort((a, b) => a.editedAt - b.editedAt || a.card.createdAt - b.card.createdAt); +} + +export function cardsDuration(slots: CardSlot[]): number { + return slots.reduce((sum, s) => sum + s.card.duration, 0); +} + +/** + * Edited time -> program time: adds the duration of every card played + * before it. A card sitting exactly at `editedTime` counts by default + * (content there starts after its card -- right for captions); pass + * includeCardsAtPoint: false to land at the card's own start instead + * (right for a chapter marker, whose chapter should include its card). + */ +export function editedToProgramTime( + editedTime: number, + slots: CardSlot[], + { includeCardsAtPoint = true }: { includeCardsAtPoint?: boolean } = {} +): number { + let shift = 0; + for (const slot of slots) { + const before = slot.editedAt < editedTime - EPSILON; + const atPoint = Math.abs(slot.editedAt - editedTime) <= EPSILON; + if (before || (atPoint && includeCardsAtPoint)) shift += slot.card.duration; + } + return editedTime + shift; +} + +/** + * Program time -> where that is: inside a card (with how far into it), or + * at an edited-timeline time. + */ +export function locateProgramTime( + programTime: number, + slots: CardSlot[] +): { editedTime: number; card: CardSlot | null; cardOffset: number } { + let shift = 0; + for (const slot of slots) { + const cardStart = slot.editedAt + shift; + if (programTime < cardStart) break; + if (programTime < cardStart + slot.card.duration) { + return { editedTime: slot.editedAt, card: slot, cardOffset: programTime - cardStart }; + } + shift += slot.card.duration; + } + return { editedTime: Math.max(0, programTime - shift), card: null, cardOffset: 0 }; +} + +/** + * The render's play order: source ranges, split wherever a card is + * inserted, with the cards in between. With no cards this is exactly the + * playable ranges. + */ +export function buildProgramItems(ranges: PlayableRange[], slots: CardSlot[]): ProgramItem[] { + const items: ProgramItem[] = []; + let next = 0; + let edited = 0; + for (const r of ranges) { + let start = r.start; + const rangeEditedEnd = edited + (r.end - r.start); + while (next < slots.length && slots[next].editedAt < rangeEditedEnd - EPSILON) { + const at = r.start + (slots[next].editedAt - edited); + if (at > start + EPSILON) items.push({ kind: "source", start, end: at }); + start = Math.max(start, at); + items.push({ kind: "card", card: slots[next].card }); + next++; + } + if (r.end > start + EPSILON) items.push({ kind: "source", start, end: r.end }); + edited = rangeEditedEnd; + } + for (; next < slots.length; next++) items.push({ kind: "card", card: slots[next].card }); + return items; +} + +export function programItemDuration(item: ProgramItem): number { + return item.kind === "card" ? item.card.duration : item.end - item.start; +} + +/** + * An edited-time span (a caption cue, a word) in program time. Its start + * lands after any card at that moment and its end before one, so a cue + * that ends right where a card begins never stays up over the card. + */ +export function editedRangeToProgram(start: number, end: number, slots: CardSlot[]): { start: number; end: number } { + return { + start: editedToProgramTime(start, slots), + end: editedToProgramTime(end, slots, { includeCardsAtPoint: false }), + }; +} + +/** Each card in the program with the program time it starts at. */ +export function cardStartTimes(items: ProgramItem[]): { start: number; card: CardOperation }[] { + const starts: { start: number; card: CardOperation }[] = []; + let t = 0; + for (const item of items) { + if (item.kind === "card") starts.push({ start: t, card: item.card }); + t += programItemDuration(item); + } + return starts; +} diff --git a/server/src/lib/timeline/program.test.ts b/server/src/lib/timeline/program.test.ts new file mode 100644 index 0000000..5f0b449 --- /dev/null +++ b/server/src/lib/timeline/program.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { + buildProgramItems, + cardStartTimes, + cardsDuration, + editedRangeToProgram, + editedToProgramTime, + locateProgramTime, + placeCards, +} from "@/lib/timeline/program"; +import { computePlayableRanges } from "@/lib/timeline/cuts"; +import type { CardOperation, CutOperation, EditOperation } from "@/types/edit-operation"; + +const card = (id: string, at: number, duration = 3, createdAt = 0): CardOperation => ({ + id, + type: "card", + at, + duration, + template: "title", + title: id, + background: "#000000", + createdAt, +}); +const cut = (start: number, end: number): CutOperation => ({ id: `cut-${start}`, type: "cut", start, end, createdAt: 0 }); +const split = (timestamp: number): EditOperation => ({ id: `split-${timestamp}`, type: "split", timestamp, createdAt: 0 }); + +function program(ops: EditOperation[], duration = 100) { + const ranges = computePlayableRanges( + duration, + ops.filter((op): op is CutOperation => op.type === "cut") + ); + const slots = placeCards(ops, ranges, duration); + return { ranges, slots, items: buildProgramItems(ranges, slots) }; +} + +describe("program without cards", () => { + it("is exactly the playable ranges, with no time shift", () => { + const { ranges, slots, items } = program([cut(10, 20), split(50)]); + expect(slots).toEqual([]); + expect(items).toEqual(ranges.map((r) => ({ kind: "source", ...r }))); + expect(editedToProgramTime(42, slots)).toBe(42); + }); +}); + +describe("placeCards", () => { + it("places an intro, a mid-episode card and an outro in play order", () => { + const { slots } = program([card("outro", 100), card("mid", 40), card("intro", 0)]); + expect(slots.map((s) => [s.card.id, s.editedAt, s.resumeAt])).toEqual([ + ["intro", 0, 0], + ["mid", 40, 40], + ["outro", 100, 100], + ]); + }); + + it("moves a card whose moment was cut to the next kept content", () => { + const { slots } = program([cut(35, 45), card("mid", 40)]); + expect(slots[0]).toMatchObject({ editedAt: 35, resumeAt: 45 }); + }); + + it("drops a card whose whole scene was cut, but keeps intro and outro", () => { + const { slots } = program([split(40), split(60), cut(40, 60), card("gone", 40), card("intro", 0), card("outro", 100)]); + expect(slots.map((s) => s.card.id)).toEqual(["intro", "outro"]); + }); + + it("keeps cards at the same moment in creation order", () => { + const { slots } = program([card("second", 40, 3, 2), card("first", 40, 3, 1)]); + expect(slots.map((s) => s.card.id)).toEqual(["first", "second"]); + }); +}); + +describe("buildProgramItems", () => { + it("splits the source range where a card is inserted", () => { + const { items } = program([card("intro", 0, 2), card("mid", 40, 3), cut(80, 100), card("outro", 100, 4)]); + expect(items).toEqual([ + { kind: "card", card: expect.objectContaining({ id: "intro" }) }, + { kind: "source", start: 0, end: 40 }, + { kind: "card", card: expect.objectContaining({ id: "mid" }) }, + { kind: "source", start: 40, end: 80 }, + { kind: "card", card: expect.objectContaining({ id: "outro" }) }, + ]); + }); +}); + +describe("time mapping", () => { + const { slots } = program([card("intro", 0, 2), card("mid", 40, 3)]); + + it("shifts edited time by every card before it", () => { + expect(cardsDuration(slots)).toBe(5); + expect(editedToProgramTime(10, slots)).toBe(12); + expect(editedToProgramTime(50, slots)).toBe(55); + }); + + it("puts content at a card's moment after the card, and chapter markers at its start", () => { + expect(editedToProgramTime(40, slots)).toBe(45); + expect(editedToProgramTime(40, slots, { includeCardsAtPoint: false })).toBe(42); + expect(editedToProgramTime(0, slots, { includeCardsAtPoint: false })).toBe(0); + }); + + it("locates program time inside a card or back on the edited timeline", () => { + expect(locateProgramTime(1, slots)).toMatchObject({ card: { card: { id: "intro" } }, cardOffset: 1 }); + expect(locateProgramTime(12, slots)).toMatchObject({ card: null, editedTime: 10 }); + expect(locateProgramTime(43.5, slots)).toMatchObject({ card: { card: { id: "mid" } }, cardOffset: 1.5 }); + expect(locateProgramTime(55, slots)).toMatchObject({ card: null, editedTime: 50 }); + }); + + it("round-trips edited -> program -> edited outside cards", () => { + for (const t of [0.5, 20, 39.9, 40.1, 99]) { + expect(locateProgramTime(editedToProgramTime(t, slots), slots).editedTime).toBeCloseTo(t); + } + }); +}); + +describe("editedRangeToProgram and cardStartTimes", () => { + const { slots, items } = program([card("intro", 0, 2), card("mid", 40, 3)]); + + it("keeps a span that ends where a card starts from running over the card", () => { + expect(editedRangeToProgram(38, 40, slots)).toEqual({ start: 40, end: 42 }); + expect(editedRangeToProgram(40, 41, slots)).toEqual({ start: 45, end: 46 }); + }); + + it("lists each card's start on the program timeline", () => { + expect(cardStartTimes(items).map((c) => [c.card.id, c.start])).toEqual([ + ["intro", 0], + ["mid", 42], + ]); + }); +}); diff --git a/server/src/lib/timeline/program.ts b/server/src/lib/timeline/program.ts new file mode 100644 index 0000000..2219864 --- /dev/null +++ b/server/src/lib/timeline/program.ts @@ -0,0 +1,178 @@ +import type { CardOperation, EditOperation, SplitOperation } from "@/types/edit-operation"; +import type { PlayableRange } from "@/types/timeline"; +import { nextPlayableTime, sourceTimeToEditedTime } from "@/lib/timeline/cuts"; + +/** + * The "program" is what actually plays: the kept source ranges with title + * cards inserted between them. Cards add time that isn't in the source, so + * three clocks exist: + * + * source time -- the original video (transcript, cuts, splits, cards' `at`) + * edited time -- source with cuts removed (lib/timeline/cuts.ts) + * program time -- edited time with cards inserted (what renders) + * + * Everything that already works in edited time (captions, chapters) shifts + * into program time through editedToProgramTime, so cuts and cards compose + * without either knowing about the other. Duplicated, not imported, across + * the client/server boundary like cuts.ts -- keep both copies identical. + */ + +const EPSILON = 0.001; + +/** A card placed on the edited timeline. */ +export type CardSlot = { + card: CardOperation; + /** Edited time the card plays at (before the source content there). */ + editedAt: number; + /** Source time playback resumes at once the card has played. */ + resumeAt: number; +}; + +export type ProgramItem = + | { kind: "source"; start: number; end: number } + | { kind: "card"; card: CardOperation }; + +export function cardOperations(operations: EditOperation[]): CardOperation[] { + return operations.filter((op): op is CardOperation => op.type === "card"); +} + +function splitTimes(operations: EditOperation[]): number[] { + return operations + .filter((op): op is SplitOperation => op.type === "split" && op.timestamp > 0) + .map((op) => op.timestamp) + .sort((a, b) => a - b); +} + +/** + * Places each card on the edited timeline, in play order. + * + * A card plays before the content at its `at` time; if that moment was + * cut, before the next content that wasn't. A card belongs to the scene + * starting at `at`, so when that whole scene is cut its card goes too -- + * otherwise cutting "Part 2" would leave its title card announcing Part 3. + * Intro (at 0) and outro (at the end) cards always stay. + */ +export function placeCards( + operations: EditOperation[], + ranges: PlayableRange[], + sourceDuration: number +): CardSlot[] { + const splits = splitTimes(operations); + const editedDuration = ranges.reduce((sum, r) => sum + (r.end - r.start), 0); + + const slots: CardSlot[] = []; + for (const card of cardOperations(operations)) { + const isOutro = card.at >= sourceDuration - EPSILON; + const isIntro = card.at <= EPSILON; + if (!isIntro && !isOutro) { + const sceneEnd = splits.find((t) => t > card.at + EPSILON) ?? sourceDuration; + const sceneHasContent = ranges.some((r) => r.start < sceneEnd && r.end > card.at); + if (!sceneHasContent) continue; + } + const resumeAt = isOutro ? null : nextPlayableTime(card.at, ranges); + slots.push( + resumeAt === null + ? { card, editedAt: editedDuration, resumeAt: sourceDuration } + : { card, editedAt: sourceTimeToEditedTime(resumeAt, ranges), resumeAt } + ); + } + return slots.sort((a, b) => a.editedAt - b.editedAt || a.card.createdAt - b.card.createdAt); +} + +export function cardsDuration(slots: CardSlot[]): number { + return slots.reduce((sum, s) => sum + s.card.duration, 0); +} + +/** + * Edited time -> program time: adds the duration of every card played + * before it. A card sitting exactly at `editedTime` counts by default + * (content there starts after its card -- right for captions); pass + * includeCardsAtPoint: false to land at the card's own start instead + * (right for a chapter marker, whose chapter should include its card). + */ +export function editedToProgramTime( + editedTime: number, + slots: CardSlot[], + { includeCardsAtPoint = true }: { includeCardsAtPoint?: boolean } = {} +): number { + let shift = 0; + for (const slot of slots) { + const before = slot.editedAt < editedTime - EPSILON; + const atPoint = Math.abs(slot.editedAt - editedTime) <= EPSILON; + if (before || (atPoint && includeCardsAtPoint)) shift += slot.card.duration; + } + return editedTime + shift; +} + +/** + * Program time -> where that is: inside a card (with how far into it), or + * at an edited-timeline time. + */ +export function locateProgramTime( + programTime: number, + slots: CardSlot[] +): { editedTime: number; card: CardSlot | null; cardOffset: number } { + let shift = 0; + for (const slot of slots) { + const cardStart = slot.editedAt + shift; + if (programTime < cardStart) break; + if (programTime < cardStart + slot.card.duration) { + return { editedTime: slot.editedAt, card: slot, cardOffset: programTime - cardStart }; + } + shift += slot.card.duration; + } + return { editedTime: Math.max(0, programTime - shift), card: null, cardOffset: 0 }; +} + +/** + * The render's play order: source ranges, split wherever a card is + * inserted, with the cards in between. With no cards this is exactly the + * playable ranges. + */ +export function buildProgramItems(ranges: PlayableRange[], slots: CardSlot[]): ProgramItem[] { + const items: ProgramItem[] = []; + let next = 0; + let edited = 0; + for (const r of ranges) { + let start = r.start; + const rangeEditedEnd = edited + (r.end - r.start); + while (next < slots.length && slots[next].editedAt < rangeEditedEnd - EPSILON) { + const at = r.start + (slots[next].editedAt - edited); + if (at > start + EPSILON) items.push({ kind: "source", start, end: at }); + start = Math.max(start, at); + items.push({ kind: "card", card: slots[next].card }); + next++; + } + if (r.end > start + EPSILON) items.push({ kind: "source", start, end: r.end }); + edited = rangeEditedEnd; + } + for (; next < slots.length; next++) items.push({ kind: "card", card: slots[next].card }); + return items; +} + +export function programItemDuration(item: ProgramItem): number { + return item.kind === "card" ? item.card.duration : item.end - item.start; +} + +/** + * An edited-time span (a caption cue, a word) in program time. Its start + * lands after any card at that moment and its end before one, so a cue + * that ends right where a card begins never stays up over the card. + */ +export function editedRangeToProgram(start: number, end: number, slots: CardSlot[]): { start: number; end: number } { + return { + start: editedToProgramTime(start, slots), + end: editedToProgramTime(end, slots, { includeCardsAtPoint: false }), + }; +} + +/** Each card in the program with the program time it starts at. */ +export function cardStartTimes(items: ProgramItem[]): { start: number; card: CardOperation }[] { + const starts: { start: number; card: CardOperation }[] = []; + let t = 0; + for (const item of items) { + if (item.kind === "card") starts.push({ start: t, card: item.card }); + t += programItemDuration(item); + } + return starts; +} From 9bac2e2f6453bcb53c6c2bc9d87a47bc3c32a148 Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 01:25:07 +0530 Subject: [PATCH 09/36] feat(server): lay out title card text and write it as ASS layout.ts turns a card into positioned lines as percentages of the frame (shared with the client preview, so both place the same card the same way at any size), picks dark or light text from the background's luminance, and fades the text in and out. ass.ts writes every card's text into one ASS file timed on the program timeline, with the output size as its script resolution. Co-Authored-By: Claude Opus 5.5 --- server/src/lib/captions/format.ts | 4 +- server/src/lib/cards/ass.ts | 74 +++++++++++++++++++++++++ server/src/lib/cards/cards.test.ts | 77 ++++++++++++++++++++++++++ server/src/lib/cards/layout.ts | 88 ++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 server/src/lib/cards/ass.ts create mode 100644 server/src/lib/cards/cards.test.ts create mode 100644 server/src/lib/cards/layout.ts diff --git a/server/src/lib/captions/format.ts b/server/src/lib/captions/format.ts index 0fa0889..4197ca7 100644 --- a/server/src/lib/captions/format.ts +++ b/server/src/lib/captions/format.ts @@ -44,14 +44,14 @@ export function toVtt(cues: CaptionCue[]): string { return `WEBVTT\n\n${body}`; } -function assTimestamp(seconds: number): string { +export function assTimestamp(seconds: number): string { const { h, m, s, ms } = splitTime(seconds); // ASS wants centiseconds (2 digits), not milliseconds (3). return `${h}:${pad(m)}:${pad(s)}.${pad(Math.floor(ms / 10))}`; } /** Strips characters that would break the {\...} override-tag syntax if they appeared in transcript text. */ -function escapeAssText(text: string): string { +export function escapeAssText(text: string): string { return text.replace(/[{}\\]/g, ""); } diff --git a/server/src/lib/cards/ass.ts b/server/src/lib/cards/ass.ts new file mode 100644 index 0000000..05e6242 --- /dev/null +++ b/server/src/lib/cards/ass.ts @@ -0,0 +1,74 @@ +import { assTimestamp, escapeAssText } from "@/lib/captions/format"; +import { hexToAssColor } from "@/lib/captions/style"; +import { + CARD_FONT, + CARD_SECONDARY_OPACITY, + CARD_TEXT_FADE_SECONDS, + CARD_TEXT_WIDTH_PERCENT, + cardTextColor, + layoutCard, + type CardLine, +} from "@/lib/cards/layout"; +import type { CardOperation } from "@/types/edit-operation"; + +/** Numpad alignment for each anchor: bottom-center, top-center, center. */ +const ALIGNMENT: Record = { above: 2, below: 8, center: 5 }; + +/** + * One .ass file with the text of every title card, timed on the program + * timeline, burned in over the rendered program in a single subtitles pass + * (the cards' solid backgrounds come from ffmpeg's color source -- see + * lib/ffmpeg/plan.ts). PlayResX/Y is the output size, so lib/cards/layout.ts's + * percentages land where the preview puts them. + * + * Card text was already refused at the API if it held braces, backslashes + * or control characters; escapeAssText strips them again here anyway, since + * a stray brace would be read as an override tag. + */ +export function toCardsAss( + cards: { start: number; card: CardOperation }[], + frame: { width: number; height: number } +): string { + const { width, height } = frame; + const marginH = Math.round((width * (100 - CARD_TEXT_WIDTH_PERCENT)) / 200); + const secondaryAlpha = Math.round((1 - CARD_SECONDARY_OPACITY) * 255) + .toString(16) + .padStart(2, "0") + .toUpperCase(); + + const events = cards.flatMap(({ start, card }) => { + const color = hexToAssColor(cardTextColor(card.background)).replace("&H00", "&H"); + const fadeMs = Math.round(Math.min(CARD_TEXT_FADE_SECONDS, card.duration / 4) * 1000); + return layoutCard(card).map((line) => { + const x = Math.round(width / 2); + const y = Math.round((line.yPercent / 100) * height); + const size = Math.round((line.sizePercent / 100) * height); + const tags = [ + `\\an${ALIGNMENT[line.anchor]}`, + `\\pos(${x},${y})`, + `\\fs${size}`, + `\\b${line.bold ? 1 : 0}`, + `\\i${line.italic ? 1 : 0}`, + `\\c${color}&`, + ...(line.secondary ? [`\\alpha&H${secondaryAlpha}&`] : []), + `\\fad(${fadeMs},${fadeMs})`, + ].join(""); + return `Dialogue: 0,${assTimestamp(start)},${assTimestamp(start + card.duration)},Card,,0,0,0,,{${tags}}${escapeAssText(line.text)}`; + }); + }); + + return `[Script Info] +ScriptType: v4.00+ +WrapStyle: 0 +PlayResX: ${width} +PlayResY: ${height} + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Card,${CARD_FONT},${Math.round(height * 0.08)},&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,${marginH},${marginH},0,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +${events.join("\n")} +`; +} diff --git a/server/src/lib/cards/cards.test.ts b/server/src/lib/cards/cards.test.ts new file mode 100644 index 0000000..6c9f162 --- /dev/null +++ b/server/src/lib/cards/cards.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { toCardsAss } from "@/lib/cards/ass"; +import { cardTextColor, cardTextOpacity, layoutCard } from "@/lib/cards/layout"; +import type { CardOperation } from "@/types/edit-operation"; + +const card: CardOperation = { + id: "c1", + type: "card", + at: 0, + duration: 3, + template: "chapter", + title: "Pricing", + subtitle: "Part 2", + background: "#111418", + createdAt: 0, +}; + +describe("layoutCard", () => { + it("puts a chapter's kicker above the title, uppercased", () => { + expect(layoutCard(card).map((l) => [l.text, l.anchor])).toEqual([ + ["PART 2", "above"], + ["Pricing", "below"], + ]); + }); + + it("centers a lone title when there's no subtitle", () => { + expect(layoutCard({ template: "title", title: "Welcome" })).toEqual([ + expect.objectContaining({ text: "Welcome", anchor: "center", yPercent: 50 }), + ]); + }); + + it("quotes a quote and attributes it", () => { + expect(layoutCard({ template: "quote", title: "Ship it", subtitle: "Ada" }).map((l) => l.text)).toEqual([ + "“Ship it”", + "— Ada", + ]); + }); +}); + +describe("cardTextColor", () => { + it("uses white on dark backgrounds and near-black on light ones", () => { + expect(cardTextColor("#111418")).toBe("#FFFFFF"); + expect(cardTextColor("#F5F0E6")).toBe("#111111"); + }); +}); + +describe("cardTextOpacity", () => { + it("fades in, holds, and fades out", () => { + expect(cardTextOpacity(0, 3)).toBe(0); + expect(cardTextOpacity(1.5, 3)).toBe(1); + expect(cardTextOpacity(3, 3)).toBe(0); + }); +}); + +describe("toCardsAss", () => { + const ass = toCardsAss([{ start: 12.5, card }], { width: 1920, height: 1080 }); + + it("uses the output size as the script resolution", () => { + expect(ass).toContain("PlayResX: 1920\nPlayResY: 1080"); + }); + + it("times each line to the card's slot on the program timeline", () => { + const events = ass.split("\n").filter((l) => l.startsWith("Dialogue:")); + expect(events).toHaveLength(2); + for (const e of events) expect(e).toContain("0:00:12.50,0:00:15.50"); + }); + + it("positions lines as percentages of the frame, in white on a dark card", () => { + expect(ass).toContain("\\an8\\pos(960,497)\\fs86\\b1"); + expect(ass).toContain("\\c&HFFFFFF&"); + }); + + it("strips override-tag characters from card text", () => { + const out = toCardsAss([{ start: 0, card: { ...card, title: "a{\\fs999}b" } }], { width: 1920, height: 1080 }); + expect(out).toContain("}afs999b"); + }); +}); diff --git a/server/src/lib/cards/layout.ts b/server/src/lib/cards/layout.ts new file mode 100644 index 0000000..f2aac23 --- /dev/null +++ b/server/src/lib/cards/layout.ts @@ -0,0 +1,88 @@ +import type { CardOperation } from "@/types/edit-operation"; + +/** + * Where each line of a title card goes, as percentages of the frame, so + * the HTML preview (CardPreview.tsx, in cqh units) and the burned-in ASS + * (lib/cards/ass.ts, against PlayResX/Y = the output size) lay out the + * same card the same way at any resolution. Duplicated, not imported, + * across the client/server boundary -- keep both copies identical. + * + * `anchor` says which edge of the text block sits on `yPercent`: a title + * anchored "above" a line grows upward as it wraps, and a subtitle + * anchored "below" grows downward, so the two never overlap. + */ +export type CardLine = { + text: string; + /** Font size, percent of frame height. */ + sizePercent: number; + anchor: "above" | "below" | "center"; + yPercent: number; + bold: boolean; + italic: boolean; + /** Secondary lines render slightly faded. */ + secondary: boolean; +}; + +export const CARD_FONT = "Arial"; +/** Text wraps inside this much of the frame width. */ +export const CARD_TEXT_WIDTH_PERCENT = 80; +/** Card text fades in and out over this long (the background cuts in). */ +export const CARD_TEXT_FADE_SECONDS = 0.25; +export const CARD_SECONDARY_OPACITY = 0.8; + +export function layoutCard(card: Pick): CardLine[] { + const title = card.title.trim(); + const subtitle = card.subtitle?.trim() ?? ""; + const line = (text: string, sizePercent: number, anchor: CardLine["anchor"], yPercent: number, rest: Partial = {}): CardLine => ({ + text, + sizePercent, + anchor, + yPercent, + bold: false, + italic: false, + secondary: false, + ...rest, + }); + + switch (card.template) { + case "chapter": + return subtitle + ? [ + line(subtitle.toUpperCase(), 3.5, "above", 44, { bold: true, secondary: true }), + line(title, 8, "below", 46, { bold: true }), + ] + : [line(title, 8, "center", 50, { bold: true })]; + case "quote": + return [ + line(`“${title}”`, 6, subtitle ? "above" : "center", subtitle ? 55 : 50, { italic: true }), + ...(subtitle ? [line(`— ${subtitle}`, 3.5, "below", 59, { secondary: true })] : []), + ]; + case "outro": + case "title": + default: { + const size = card.template === "outro" ? 7 : 8; + return subtitle + ? [line(title, size, "above", 52, { bold: true }), line(subtitle, 4, "below", 55, { secondary: true })] + : [line(title, size, "center", 50, { bold: true })]; + } + } +} + +/** Dark text on light backgrounds, white on dark (WCAG relative luminance). */ +export function cardTextColor(background: string): string { + const channel = (i: number) => { + const c = parseInt(background.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); + return luminance > 0.4 ? "#111111" : "#FFFFFF"; +} + +/** Opacity of card text `offset` seconds into a card lasting `duration`. */ +export function cardTextOpacity(offset: number, duration: number): number { + const fade = Math.min(CARD_TEXT_FADE_SECONDS, duration / 4); + return Math.max(0, Math.min(1, offset / fade, (duration - offset) / fade)); +} + +/** Preset backgrounds offered in the card editor. */ +export const CARD_BACKGROUNDS = ["#111418", "#1E3A5F", "#7C2D12", "#14532D", "#F5F0E6", "#FFFFFF"] as const; From 22923075e099da4bd82254c688e07a6f07be3303 Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 01:25:07 +0530 Subject: [PATCH 10/36] feat(server): render title cards into exports Each card becomes an ffmpeg color source at the source's size and frame rate, joined into the same xfade chain as the cuts, with silence for its duration on the audio side; the cards' text is burned in over the whole program in one subtitles pass, before captions and the logo. With cards present, segments are normalized to one pixel and audio format so they can join, and color grading moves onto each footage segment so a preset doesn't recolor the cards. Projects without cards render the same graph as before. Captions and embedded chapters move onto the program timeline; a chapter that starts where a card plays starts with the card. MP3/WAV exports get the cards' silence so chapters stay in sync. Clips skip cards. Verified with ffmpeg 9.0.2 on a synthetic clip with an intro, a cut, a chapter card and an outro: every card and segment lands where the program model says (video 23.857s, audio 23.850s), and a beep every second lands at its predicted time before and after each card. Co-Authored-By: Claude Opus 5.5 --- server/src/lib/ffmpeg/plan.test.ts | 82 +++++++++++++ server/src/lib/ffmpeg/plan.ts | 166 ++++++++++++++++++++------ server/src/lib/ffmpeg/render-job.ts | 58 +++++++-- server/src/lib/publishing/chapters.ts | 17 +++ 4 files changed, 278 insertions(+), 45 deletions(-) diff --git a/server/src/lib/ffmpeg/plan.test.ts b/server/src/lib/ffmpeg/plan.test.ts index bfbd447..114744c 100644 --- a/server/src/lib/ffmpeg/plan.test.ts +++ b/server/src/lib/ffmpeg/plan.test.ts @@ -342,3 +342,85 @@ describe("reframe", () => { expect(() => buildRenderArgs({ ...baseArgs, reframe: { width: 1080.5, height: 1920, cropX: 0.5 } })).toThrow(); }); }); + +describe("title cards", () => { + const card = { + id: "c1", + type: "card" as const, + at: 0, + duration: 3, + template: "title" as const, + title: "Welcome", + background: "#1e3a5f", + createdAt: 0, + }; + const program = [ + { kind: "card" as const, card }, + { kind: "source" as const, start: 0, end: 10 }, + { kind: "source" as const, start: 20, end: 30 }, + ]; + const cardFrame = { width: 1920, height: 1080, textPath: "/data/cards.ass" }; + const graph = (extra: Partial[0]> = {}) => { + const args = buildRenderArgs({ ...baseArgs, program, cardFrame, ...extra }); + return args[args.indexOf("-filter_complex") + 1]; + }; + + it("generates each card as a solid-color segment at the source's size and frame rate", () => { + expect(graph()).toContain( + "color=c=0x1E3A5F:s=1920x1080:r=30000/1001:d=3.000,format=yuv420p,setsar=1,tpad=stop_mode=clone:stop_duration=0.1[v0]" + ); + }); + + it("normalizes source segments so they can join the cards", () => { + expect(graph()).toContain( + "[0:v]trim=start=0.000:end=10.000,setpts=PTS-STARTPTS,fps=30000/1001,format=yuv420p,setsar=1,tpad=stop_mode=clone:stop_duration=0.1[v1]" + ); + }); + + it("offsets each join by the durations before it, cards included", () => { + const g = graph(); + expect(g).toContain("[v0][v1]xfade=transition=fade:duration=0.030:offset=2.970[vx1]"); + expect(g).toContain("[vx1][v2]xfade=transition=fade:duration=0.030:offset=12.940[outv]"); + }); + + it("gives cards silence in a shared audio format", () => { + const g = graph(); + expect(g).toContain( + "anullsrc=r=48000:cl=stereo,atrim=duration=3.000,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a0]" + ); + expect(g).toContain( + "[0:a]atrim=start=0.000:end=10.000,asetpts=PTS-STARTPTS,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[a1]" + ); + }); + + it("grades only the footage, then burns card text in before captions", () => { + const g = graph({ + filterId: "bw", + srtPath: "/data/subs.srt", + captionStyle: DEFAULT_CAPTION_STYLE, + videoHeight: 1080, + videoWidth: 1920, + }); + expect(g).not.toContain("[outv]hue"); + expect(g).toContain("[0:v]trim=start=0.000:end=10.000,setpts=PTS-STARTPTS,hue=s=0"); + expect(g.split(";").find((chain) => chain.startsWith("color="))).not.toContain("hue"); + expect(g.indexOf("subtitles=filename='/data/cards.ass'[carded]")).toBeLessThan( + g.indexOf("subtitles=filename='/data/subs.srt'") + ); + expect(g).toMatch(/\[carded\]subtitles=filename='\/data\/subs\.srt'/); + }); + + it("refuses cards without the frame to generate them at, and bad values", () => { + expect(() => buildRenderArgs({ ...baseArgs, program })).toThrow(/frame size/); + expect(() => buildRenderArgs({ ...baseArgs, program, cardFrame: { ...cardFrame, width: 0 } })).toThrow( + /frame size/ + ); + const badColor = [{ kind: "card" as const, card: { ...card, background: "red" } }, program[1]]; + expect(() => buildRenderArgs({ ...baseArgs, program: badColor, cardFrame })).toThrow(/background color/); + }); + + it("adds card silence to audio-only exports", () => { + const args = buildAudioOnlyRenderArgs({ ...baseArgs, program, format: "mp3" }); + expect(args[args.indexOf("-filter_complex") + 1]).toContain("anullsrc=r=48000:cl=stereo,atrim=duration=3.000"); + }); +}); diff --git a/server/src/lib/ffmpeg/plan.ts b/server/src/lib/ffmpeg/plan.ts index 132af5f..6ed928f 100644 --- a/server/src/lib/ffmpeg/plan.ts +++ b/server/src/lib/ffmpeg/plan.ts @@ -1,4 +1,5 @@ import type { PlayableRange } from "@/types/timeline"; +import type { ProgramItem } from "@/lib/timeline/program"; import { getFfmpegFilter } from "@/lib/ffmpeg/filters"; import { buildPropertiesFilter } from "@/lib/ffmpeg/properties"; import { buildForceStyle, type CaptionStyle } from "@/lib/captions/style"; @@ -78,6 +79,56 @@ function assertValidRanges(playableRanges: PlayableRange[]): void { } } +/** + * What to render, in order: the program (lib/timeline/program.ts) when the + * project has title cards, otherwise just the playable ranges. Validated + * here since card durations and colors end up in the filtergraph. + */ +function resolveItems(playableRanges: PlayableRange[], program?: ProgramItem[]): ProgramItem[] { + if (!program) { + assertValidRanges(playableRanges); + return playableRanges.map((r) => ({ kind: "source", start: r.start, end: r.end })); + } + assertValidRanges(program.flatMap((item) => (item.kind === "source" ? [item] : []))); + for (const item of program) { + if (item.kind !== "card") continue; + const { duration, background } = item.card; + if (!Number.isFinite(duration) || duration <= 0 || duration > 60) throw new Error("Invalid card duration."); + if (!/^#[0-9a-fA-F]{6}$/.test(background)) throw new Error("Invalid card background color."); + } + return program; +} + +function assertValidCardFrame(frame: { width: number; height: number }): void { + const { width, height } = frame; + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || width > 8192 || height > 8192) { + throw new Error(`Invalid card frame size: ${width}x${height}`); + } +} + +function itemDuration(item: ProgramItem): number { + return item.kind === "card" ? item.card.duration : item.end - item.start; +} + +const hasCards = (items: ProgramItem[]) => items.some((item) => item.kind === "card"); + +/** + * Cards join the source through xfade/acrossfade, which need identical + * frame size, rate, pixel format and timebase (video) and sample format, + * rate and layout (audio) on both sides. A generated color source never + * matches a decoded file on its own, so when cards are present every + * segment is normalized to these. Without cards the graph is left exactly + * as it always was. + */ +const CARD_AUDIO_FORMAT = "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo"; +/** + * The rate comes from fps= (sources) and the color source's own r= (cards), + * which also gives both the same 1/rate timebase. Don't add settb here: + * measured on ffmpeg 9, forcing another timebase after fps= brings back + * the dropped-footage join the fps= fix exists for. + */ +const CARD_VIDEO_FORMAT = "format=yuv420p,setsar=1"; + /** * Crossfade length at each join: entry i is the fade between range i-1 and * range i (entry 0 is unused). Each is clamped to at most half of either @@ -85,27 +136,29 @@ function assertValidRanges(playableRanges: PlayableRange[]): void { * between two nearby cuts can't make the transition eat more than that * sliver. Shared by the video and audio graphs so they stay in sync. */ -function computeCrossfades(playableRanges: PlayableRange[]): number[] { - return playableRanges.map((r, i) => { - if (i === 0) return 0; - const previous = playableRanges[i - 1]; - return Math.min(CUT_CROSSFADE_SECONDS, (previous.end - previous.start) / 2, (r.end - r.start) / 2); - }); +function computeCrossfades(durations: number[]): number[] { + return durations.map((d, i) => (i === 0 ? 0 : Math.min(CUT_CROSSFADE_SECONDS, durations[i - 1] / 2, d / 2))); } -/** atrim + acrossfade chains ending in [outa]: the edited program's audio, before any cleanup. */ -function buildAudioEditChains(playableRanges: PlayableRange[]): string[] { - const chains = playableRanges.map( - (r, i) => `[0:a]atrim=start=${r.start.toFixed(3)}:end=${r.end.toFixed(3)},asetpts=PTS-STARTPTS[a${i}]` +/** + * atrim + acrossfade chains ending in [outa]: the edited program's audio, + * before any cleanup. Cards contribute silence for their duration. + */ +function buildAudioEditChains(items: ProgramItem[]): string[] { + const normalize = hasCards(items) ? `,${CARD_AUDIO_FORMAT}` : ""; + const chains = items.map((item, i) => + item.kind === "source" + ? `[0:a]atrim=start=${item.start.toFixed(3)}:end=${item.end.toFixed(3)},asetpts=PTS-STARTPTS${normalize}[a${i}]` + : `anullsrc=r=48000:cl=stereo,atrim=duration=${item.card.duration.toFixed(3)}${normalize}[a${i}]` ); - if (playableRanges.length === 1) { + if (items.length === 1) { chains.push("[a0]anull[outa]"); return chains; } - const crossfades = computeCrossfades(playableRanges); + const crossfades = computeCrossfades(items.map(itemDuration)); let label = "a0"; - for (let i = 1; i < playableRanges.length; i++) { - const next = i === playableRanges.length - 1 ? "outa" : `ax${i}`; + for (let i = 1; i < items.length; i++) { + const next = i === items.length - 1 ? "outa" : `ax${i}`; chains.push(`[${label}][a${i}]acrossfade=d=${crossfades[i].toFixed(3)}[${next}]`); label = next; } @@ -121,11 +174,13 @@ function buildAudioEditChains(playableRanges: PlayableRange[]): string[] { export function buildLoudnessAnalysisArgs(args: { inputPath: string; playableRanges: PlayableRange[]; + /** With title cards: the program, whose cards add silence (see resolveItems). */ + program?: ProgramItem[]; /** From audio-filters.ts#buildLoudnessAnalysisChain. */ analysisChain: string; }): string[] { - assertValidRanges(args.playableRanges); - const chains = [...buildAudioEditChains(args.playableRanges), `[outa]${args.analysisChain}[analysis]`]; + const items = resolveItems(args.playableRanges, args.program); + const chains = [...buildAudioEditChains(items), `[outa]${args.analysisChain}[analysis]`]; return ["-i", args.inputPath, "-filter_complex", chains.join(";"), "-map", "[analysis]", "-f", "null", "-"]; } @@ -152,13 +207,15 @@ export function buildAudioOnlyRenderArgs(args: { inputPath: string; outputPath: string; playableRanges: PlayableRange[]; + /** With title cards: the program, so cards become silence and chapters stay in sync. */ + program?: ProgramItem[]; audioFilter?: string | null; format?: "m4a" | "mp3" | "wav"; metadataPath?: string; }): string[] { - assertValidRanges(args.playableRanges); + const items = resolveItems(args.playableRanges, args.program); const format = args.format ?? "m4a"; - const chains = buildAudioEditChains(args.playableRanges); + const chains = buildAudioEditChains(items); let label = "[outa]"; if (args.audioFilter) { chains.push(`[outa]${args.audioFilter}[cleana]`); @@ -272,6 +329,14 @@ export function buildRenderArgs(args: { * xfade joins -- see the comment where it's applied. */ frameRate: string; + /** + * The program with title cards (lib/timeline/program.ts), plus what the + * card segments need to match the source: its frame size and the .ass + * file with every card's text (lib/cards/ass.ts). Omit for a project + * without cards. + */ + program?: ProgramItem[]; + cardFrame?: { width: number; height: number; textPath: string }; }): string[] { const { inputPath, @@ -292,10 +357,19 @@ export function buildRenderArgs(args: { metadataPath, reframe, frameRate, + program, + cardFrame, } = args; - assertValidRanges(playableRanges); + const items = resolveItems(playableRanges, program); if (!/^\d{1,6}(\/\d{1,6})?$/.test(frameRate)) throw new Error(`Invalid frame rate: ${frameRate}`); + const withCards = hasCards(items); + if (withCards && !cardFrame) throw new Error("Title cards need the source's frame size."); + if (cardFrame) assertValidCardFrame(cardFrame); + + const presetFilter = getFfmpegFilter(filterId); + const propertiesFilter = properties ? buildPropertiesFilter(properties) : null; + const colorFilter = [presetFilter, propertiesFilter].filter(Boolean).join(","); // fps= pins each trimmed segment to the source's frame rate. Without it, // xfade on ffmpeg 9 drops the start of every segment after a join: the @@ -313,14 +387,32 @@ export function buildRenderArgs(args: { // 14.9s, the last segment gone). xfade discards the first input's frames // after the transition, so the padding never reaches the output. const filterChains: string[] = []; - playableRanges.forEach((r, i) => { - const pad = i < playableRanges.length - 1 ? `,${SEGMENT_TAIL_PAD}` : ""; - filterChains.push( - `[0:v]trim=start=${r.start.toFixed(3)}:end=${r.end.toFixed(3)},setpts=PTS-STARTPTS,fps=${frameRate}${pad}[v${i}]` - ); + items.forEach((item, i) => { + const pad = i < items.length - 1 ? `,${SEGMENT_TAIL_PAD}` : ""; + if (!withCards) { + const r = item as Extract; + filterChains.push( + `[0:v]trim=start=${r.start.toFixed(3)}:end=${r.end.toFixed(3)},setpts=PTS-STARTPTS,fps=${frameRate}${pad}[v${i}]` + ); + return; + } + // With cards, color grading is applied per source segment so the cards + // keep their exact chosen color (a black-and-white preset shouldn't + // turn a blue card grey). + if (item.kind === "source") { + const grade = colorFilter ? `,${colorFilter}` : ""; + filterChains.push( + `[0:v]trim=start=${item.start.toFixed(3)}:end=${item.end.toFixed(3)},setpts=PTS-STARTPTS${grade},fps=${frameRate},${CARD_VIDEO_FORMAT}${pad}[v${i}]` + ); + } else { + const color = item.card.background.slice(1).toUpperCase(); + filterChains.push( + `color=c=0x${color}:s=${cardFrame!.width}x${cardFrame!.height}:r=${frameRate}:d=${item.card.duration.toFixed(3)},${CARD_VIDEO_FORMAT}${pad}[v${i}]` + ); + } }); - if (playableRanges.length === 1) { + if (items.length === 1) { filterChains.push(`[v0]null[outv]`); } else { // Chain xfade pairwise across every cut instead of a plain concat. @@ -331,14 +423,15 @@ export function buildRenderArgs(args: { // the filter's documented options, since offset math like this is easy // to get subtly wrong. The audio side uses the same crossfade lengths // (see buildAudioEditChains). - const crossfades = computeCrossfades(playableRanges); + const durations = items.map(itemDuration); + const crossfades = computeCrossfades(durations); let videoLabel = "v0"; - let cumulativeDuration = playableRanges[0].end - playableRanges[0].start; - for (let i = 1; i < playableRanges.length; i++) { - const segmentDuration = playableRanges[i].end - playableRanges[i].start; + let cumulativeDuration = durations[0]; + for (let i = 1; i < items.length; i++) { + const segmentDuration = durations[i]; const crossfade = crossfades[i]; const offset = (cumulativeDuration - crossfade).toFixed(3); - const nextVideoLabel = i === playableRanges.length - 1 ? "outv" : `vx${i}`; + const nextVideoLabel = i === items.length - 1 ? "outv" : `vx${i}`; filterChains.push( `[${videoLabel}][v${i}]xfade=transition=fade:duration=${crossfade.toFixed(3)}:offset=${offset}[${nextVideoLabel}]` ); @@ -347,7 +440,7 @@ export function buildRenderArgs(args: { } } - filterChains.push(...buildAudioEditChains(playableRanges)); + filterChains.push(...buildAudioEditChains(items)); let audioOutLabel = "[outa]"; if (audioFilter) { filterChains.push(`[outa]${audioFilter}[cleana]`); @@ -355,10 +448,7 @@ export function buildRenderArgs(args: { } let videoOutLabel = "[outv]"; - const presetFilter = getFfmpegFilter(filterId); - const propertiesFilter = properties ? buildPropertiesFilter(properties) : null; - const colorFilter = [presetFilter, propertiesFilter].filter(Boolean).join(","); - if (colorFilter) { + if (colorFilter && !withCards) { filterChains.push(`[outv]${colorFilter}[filtered]`); videoOutLabel = "[filtered]"; } @@ -368,6 +458,12 @@ export function buildRenderArgs(args: { videoOutLabel = "[reframed]"; } + // Card text sits above the footage and below captions and the logo. + if (withCards) { + filterChains.push(`${videoOutLabel}subtitles=filename='${escapeSubtitlesPath(cardFrame!.textPath)}'[carded]`); + videoOutLabel = "[carded]"; + } + if (srtPath) { // force_style's FontSize/MarginV are literal, un-scaled output pixels // -- verified empirically that ffmpeg's `original_size` option does diff --git a/server/src/lib/ffmpeg/render-job.ts b/server/src/lib/ffmpeg/render-job.ts index 2ee037f..0fa0126 100644 --- a/server/src/lib/ffmpeg/render-job.ts +++ b/server/src/lib/ffmpeg/render-job.ts @@ -5,7 +5,7 @@ import { prisma } from "@/lib/db/client"; import { resolveInDataDir, statAsset } from "@/lib/storage/local"; import { computePlayableRanges } from "@/lib/timeline/cuts"; import { buildAudioOnlyRenderArgs, buildLoudnessAnalysisArgs, buildRenderArgs } from "@/lib/ffmpeg/plan"; -import { resolveChapters, toFfmetadata } from "@/lib/publishing/chapters"; +import { resolveProgramChapters, toFfmetadata } from "@/lib/publishing/chapters"; import { clipAsCuts } from "@/lib/clips/clips"; import { captionStyleSchema } from "@/lib/validation/caption-style"; import { CLIP_ASPECTS, CLIP_OUTPUT_SIZE, type ClipAspect } from "@/types/clips"; @@ -23,6 +23,17 @@ import { import { parseStoredAudioSettings } from "@/lib/validation/audio-settings"; import { runFfmpeg, runFfmpegCapturingStderr } from "@/lib/ffmpeg/run"; import { probeVideoStream } from "@/lib/ffmpeg/probe"; +import { + buildProgramItems, + cardStartTimes, + cardsDuration, + editedRangeToProgram, + placeCards, + type CardSlot, + type ProgramItem, +} from "@/lib/timeline/program"; +import { toCardsAss } from "@/lib/cards/ass"; +import type { CaptionCue } from "@/lib/captions/generate"; import { generateCaptions, splitCuesForShorts } from "@/lib/captions/generate"; import { toAssKaraoke, toSrt } from "@/lib/captions/format"; import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@/lib/captions/style"; @@ -63,7 +74,8 @@ export async function resolveLoudnessGain( inputPath: string, playableRanges: PlayableRange[], audioSettings: AudioSettings, - jobId: string + jobId: string, + program?: ProgramItem[] ): Promise { const target = loudnessTargetLufs(audioSettings); if (target === null) return undefined; @@ -71,7 +83,7 @@ export async function resolveLoudnessGain( const measure = async (gainDb?: number) => { const analysisChain = buildLoudnessAnalysisChain(audioSettings, gainDb)!; const stderr = await runFfmpegCapturingStderr( - buildLoudnessAnalysisArgs({ inputPath, playableRanges, analysisChain }) + buildLoudnessAnalysisArgs({ inputPath, playableRanges, program, analysisChain }) ); return parseMeasuredLoudness(stderr); }; @@ -125,6 +137,16 @@ function toClipAspect(value: string): ClipAspect { return (CLIP_ASPECTS as readonly string[]).includes(value) ? (value as ClipAspect) : "9:16"; } +/** Caption cues moved from edited time onto the program timeline, around any title cards. */ +function cuesToProgramTime(cues: CaptionCue[], slots: CardSlot[]): CaptionCue[] { + if (slots.length === 0) return cues; + return cues.map((cue) => ({ + ...cue, + ...editedRangeToProgram(cue.start, cue.end, slots), + words: cue.words.map((w) => ({ ...w, ...editedRangeToProgram(w.start, w.end, slots) })), + })); +} + export const EXPORT_MIME_TYPES: Record = { mp4: "video/mp4", mp3: "audio/mpeg", @@ -164,13 +186,15 @@ export async function runRenderJob( // A clip is rendered as the project with everything outside it cut too, // so the project's own edits still apply inside the clip. + const operations = project.editOperations.map((op) => JSON.parse(op.dataJson) as EditOperation); const cuts = [ - ...project.editOperations - .map((op) => JSON.parse(op.dataJson) as EditOperation) - .filter((op): op is CutOperation => op.type === "cut"), + ...operations.filter((op): op is CutOperation => op.type === "cut"), ...(clip ? clipAsCuts(clip, project.duration) : []), ]; const playableRanges = computePlayableRanges(project.duration, cuts); + // Title cards play in full exports only: a clip is a standalone excerpt. + const cardSlots = clip ? [] : placeCards(operations, playableRanges, project.duration); + const program = cardSlots.length > 0 ? buildProgramItems(playableRanges, cardSlots) : undefined; const inputPath = resolveInDataDir(originalAsset.filePath); const outputRelativePath = path.posix.join( @@ -203,13 +227,21 @@ export async function runRenderJob( const sourceStream = isVideo ? await probeVideoStream(inputPath) : undefined; const videoDimensions = clipFrame ?? sourceStream; + // Title cards are generated at the source's own size so they join it seamlessly. + let cardFrame: { width: number; height: number; textPath: string } | undefined; + if (program && sourceStream) { + const textPath = resolveInDataDir(path.posix.join("projects", project.id, "render", `${jobId}.cards.ass`)); + await writeFile(textPath, toCardsAss(cardStartTimes(program), sourceStream), "utf-8"); + cardFrame = { width: sourceStream.width, height: sourceStream.height, textPath }; + } + if (burnInCaptions) { if (!project.transcript) throw new Error("Captions were requested but this project has no transcript."); const transcript: Transcript = { id: project.transcript.id, segments: JSON.parse(project.transcript.segmentsJson), }; - const sentenceCues = generateCaptions(transcript, cuts, project.duration); + const sentenceCues = cuesToProgramTime(generateCaptions(transcript, cuts, project.duration), cardSlots); const cues = clip ? splitCuesForShorts(sentenceCues) : sentenceCues; const subtitleExt = useKaraoke ? "ass" : "srt"; const subtitleRelativePath = path.posix.join("projects", project.id, "render", `${jobId}.${subtitleExt}`); @@ -227,7 +259,7 @@ export async function runRenderJob( const audioSettings = parseStoredAudioSettings(project.audioSettingsJson); const audioFilter = buildAudioFilterChain( audioSettings, - await resolveLoudnessGain(inputPath, playableRanges, audioSettings, jobId) + await resolveLoudnessGain(inputPath, playableRanges, audioSettings, jobId, program) ); // Episode title + chapter markers, embedded so podcast apps and players @@ -242,7 +274,11 @@ export async function runRenderJob( metadataPath = resolveInDataDir(path.posix.join("projects", project.id, "render", `${jobId}.ffmeta`)); await writeFile( metadataPath, - toFfmetadata(resolveChapters(chapters, playableRanges), getEditedDuration(playableRanges), project.name), + toFfmetadata( + resolveProgramChapters(chapters, playableRanges, cardSlots), + getEditedDuration(playableRanges) + cardsDuration(cardSlots), + project.name + ), "utf-8" ); } @@ -267,8 +303,10 @@ export async function runRenderJob( metadataPath, reframe: clip && clipFrame ? { ...clipFrame, cropX: clip.cropX } : undefined, frameRate: sourceStream!.fps, + program, + cardFrame, }) - : buildAudioOnlyRenderArgs({ inputPath, outputPath, playableRanges, audioFilter, format, metadataPath }); + : buildAudioOnlyRenderArgs({ inputPath, outputPath, playableRanges, program, audioFilter, format, metadataPath }); await runFfmpeg(args); const stats = await statAsset(outputRelativePath); diff --git a/server/src/lib/publishing/chapters.ts b/server/src/lib/publishing/chapters.ts index b7ccbf7..47dd777 100644 --- a/server/src/lib/publishing/chapters.ts +++ b/server/src/lib/publishing/chapters.ts @@ -8,6 +8,7 @@ import { type ResolvedChapter, } from "@/types/publishing"; import type { PlayableRange } from "@/types/timeline"; +import { editedToProgramTime, type CardSlot } from "@/lib/timeline/program"; /** * Chapter rules shared by AI output and user edits. Pure functions only, so @@ -95,6 +96,22 @@ export function resolveChapters(chapters: Chapter[], ranges: PlayableRange[]): R return resolved; } +/** + * resolveChapters, then onto the program timeline around any title cards. + * A chapter starting where a card plays starts with that card, so the + * marker shows the card rather than jumping past it. + */ +export function resolveProgramChapters( + chapters: Chapter[], + ranges: PlayableRange[], + slots: CardSlot[] +): ResolvedChapter[] { + return resolveChapters(chapters, ranges).map((c) => ({ + ...c, + start: c.start === 0 ? 0 : editedToProgramTime(c.start, slots, { includeCardsAtPoint: false }), + })); +} + /** "00:00 Intro" lines for a YouTube description or show notes. */ export function toYoutubeChapters(chapters: ResolvedChapter[], totalSeconds: number): string { const longForm = totalSeconds >= 3600; From 2997c189753b46eb9cae85a7e989cc7c2c304c5b Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 01:25:07 +0530 Subject: [PATCH 11/36] feat(server): chapters and transcript export on program time The Publish panel's chapters, YouTube chapter list and the exported transcript's timestamps now include title cards, so they match what the export actually plays. Co-Authored-By: Claude Opus 5.5 --- server/src/routes/publishing.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/server/src/routes/publishing.ts b/server/src/routes/publishing.ts index 66910dc..c6b4717 100644 --- a/server/src/routes/publishing.ts +++ b/server/src/routes/publishing.ts @@ -4,12 +4,13 @@ import { logger } from "@/lib/logger"; import { prisma } from "@/lib/db/client"; import { errorResponse } from "@/lib/http"; import { computePlayableRanges, getEditedDuration } from "@/lib/timeline/cuts"; +import { cardsDuration, editedRangeToProgram, placeCards } from "@/lib/timeline/program"; import { buildEditedSentences, formatTranscript } from "@/lib/publishing/edited-transcript"; import { chaptersFromPicks, cleanChapterTitle, minChapterSeconds, - resolveChapters, + resolveProgramChapters, toYoutubeChapters, } from "@/lib/publishing/chapters"; import { generateChapterPicks, generateShowNotes } from "@/lib/ai/publishing"; @@ -32,16 +33,22 @@ async function loadEditedProject(id: string) { if (project.duration === null) return fail("NO_DURATION", "This project's video duration isn't known yet.", 400); const transcript: Transcript = { id: project.transcript.id, segments: JSON.parse(project.transcript.segmentsJson) }; - const cuts = project.editOperations - .map((op) => JSON.parse(op.dataJson) as EditOperation) - .filter((op): op is CutOperation => op.type === "cut"); + const operations = project.editOperations.map((op) => JSON.parse(op.dataJson) as EditOperation); + const cuts = operations.filter((op): op is CutOperation => op.type === "cut"); const ranges = computePlayableRanges(project.duration, cuts); + const cardSlots = placeCards(operations, ranges, project.duration); return { ok: true as const, project, ranges, - editedDuration: getEditedDuration(ranges), - sentences: buildEditedSentences(transcript, cuts, project.duration), + cardSlots, + // What the export will run for, title cards included. + programDuration: getEditedDuration(ranges) + cardsDuration(cardSlots), + // Timestamps as heard in the export, title cards included. + sentences: buildEditedSentences(transcript, cuts, project.duration).map((sentence) => ({ + ...sentence, + ...editedRangeToProgram(sentence.start, sentence.end, cardSlots), + })), chapters: project.publishingMeta?.chaptersJson ? (JSON.parse(project.publishingMeta.chaptersJson) as Chapter[]) : [], showNotes: project.publishingMeta?.showNotesJson ? (JSON.parse(project.publishingMeta.showNotesJson) as ShowNotes) @@ -52,11 +59,11 @@ async function loadEditedProject(id: string) { type Loaded = Extract>, { ok: true }>; function publishingResponse(c: Context, loaded: Loaded, chapters: Chapter[], showNotes: ShowNotes | null) { - const resolved = resolveChapters(chapters, loaded.ranges); + const resolved = resolveProgramChapters(chapters, loaded.ranges, loaded.cardSlots); return c.json({ success: true, chapters: resolved, - youtubeChapters: toYoutubeChapters(resolved, loaded.editedDuration), + youtubeChapters: toYoutubeChapters(resolved, loaded.programDuration), showNotes, }); } @@ -88,7 +95,7 @@ publishingRoute.post("/:id/publishing/chapters", async (c) => { } try { - const minGap = minChapterSeconds(loaded.editedDuration); + const minGap = minChapterSeconds(loaded.programDuration); const picks = await generateChapterPicks(loaded.sentences, minGap); const chapters = chaptersFromPicks(picks, loaded.sentences, minGap); await saveMeta(id, { chaptersJson: JSON.stringify(chapters) }); From 7b786f999a424db05a64c7900ce1e93bdb305c70 Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 01:25:07 +0530 Subject: [PATCH 12/36] feat(client): preview title cards in the player The preview can't insert frames into a
); } From d7cac4215cbdd3c9b4de2ebb94fc95d018809ece Mon Sep 17 00:00:00 2001 From: amide-init Date: Sat, 26 Sep 2026 01:25:14 +0530 Subject: [PATCH 15/36] docs: document scenes, title cards and the ffmpeg 9 xfade rules Records the three clocks (source, edited, program), how cards render and preview, and the frame-rate and padding rules the render graph needs on ffmpeg 9, so nobody removes them as noise. Co-Authored-By: Claude Opus 5.5 --- claude.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/claude.md b/claude.md index ad7e49d..7ea0f9e 100644 --- a/claude.md +++ b/claude.md @@ -634,6 +634,40 @@ it; `plan.ts` then crops/scales to the clip frame, and captions are re-cut into 3-4 word Shorts cues laid out for that frame (the ASS script resolution must match the output aspect, or libass stretches glyphs). +## Scenes and title cards + +Scenes are derived, never stored: the ranges between `split` operations +(`lib/timeline/scenes.ts`); a split at exactly 0 only names the first +scene. New splits snap to the middle of a gap between words. + +A `card` operation inserts a full-screen title card (template, title, +subtitle, 1-10s, solid color) before the content at its source time `at` +-- a scene start, 0 for an intro, the duration for an outro. Cards add +time that isn't in the source, so there are three clocks: source, edited +(cuts removed, `cuts.ts`) and **program** (edited plus cards, +`lib/timeline/program.ts`, duplicated client/server). Code that already +works in edited time (captions, chapters, transcript export) shifts into +program time through `editedToProgramTime`, so cuts and cards compose +without knowing about each other. A card whose whole scene is cut is +dropped with it. Clips skip cards. + +Rendering: each card is an ffmpeg `color` source at the source's size and +frame rate, joined with the same xfade chain as cuts; all card text is +one generated `.ass` file (`lib/cards/ass.ts`) burned in over the program +before captions and the logo. User text only ever reaches ffmpeg inside +that file, and the API refuses braces, backslashes and control +characters. The preview can't insert frames into a `
diff --git a/client/src/components/editor/TransitionPicker.tsx b/client/src/components/editor/TransitionPicker.tsx new file mode 100644 index 0000000..a07fa7c --- /dev/null +++ b/client/src/components/editor/TransitionPicker.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useTimelineStore } from "@/stores/timeline-store"; +import type { TransitionKind, TransitionOperation } from "@/types/edit-operation"; + +const DURATIONS = [0.5, 1, 1.5, 2]; +const selectClass = + "h-6 rounded border border-border bg-transparent px-1 text-[0.7rem] text-foreground focus:border-primary focus:outline-none"; + +/** + * Picks the transition at one moment: into a scene (dip or, when the scene + * has a title card, crossfade), or the episode's fade in (at 0) / fade out + * (at the end), which only dip. One operation per moment; choosing "None" + * removes it. Every change is one undo step. + */ +export function TransitionPicker({ + label, + at, + current, + edge, + canCrossfade = false, +}: { + label: string; + at: number; + current: TransitionOperation | undefined; + /** The episode's start or end: dips only, worded as fades. */ + edge?: "in" | "out"; + canCrossfade?: boolean; +}) { + const addOperations = useTimelineStore((s) => s.addOperations); + const replaceOperation = useTimelineStore((s) => s.replaceOperation); + const removeOperations = useTimelineStore((s) => s.removeOperations); + + const save = (kind: TransitionKind | "none", duration: number) => { + if (kind === "none") { + if (current) removeOperations([current.id]); + return; + } + const next: TransitionOperation = { + id: crypto.randomUUID(), + type: "transition", + at, + kind, + duration, + createdAt: Date.now(), + }; + if (current) replaceOperation(current.id, next); + else addOperations([next]); + }; + + const kind = current?.kind ?? "none"; + const duration = current?.duration ?? 1; + const toward = edge === "in" ? "from" : edge === "out" ? "to" : "via"; + + return ( + + ); +} diff --git a/client/src/components/timeline/Timeline.tsx b/client/src/components/timeline/Timeline.tsx index 3d24d32..c68cccf 100644 --- a/client/src/components/timeline/Timeline.tsx +++ b/client/src/components/timeline/Timeline.tsx @@ -6,7 +6,7 @@ import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { useTranscriptStore } from "@/stores/transcript-store"; import { editedTimeToSourceTime, sourceTimeToEditedTime } from "@/lib/timeline/cuts"; -import { editedToProgramTime, locateProgramTime, programItemDuration } from "@/lib/timeline/program"; +import { editedToProgramTime, itemStartTimes, locateProgramTime, programItemDuration } from "@/lib/timeline/program"; import { programPlayhead, useProgram } from "@/lib/timeline/useProgram"; import { formatTimecode } from "@/lib/timeline/format"; import { useWaveform } from "@/lib/timeline/useWaveform"; @@ -59,6 +59,7 @@ export function Timeline() { const totalDuration = program.duration; const programTime = programPlayhead(program, currentTime, card); const playheadPosition = totalDuration > 0 ? (programTime / totalDuration) * 100 : 0; + const itemStarts = useMemo(() => itemStartTimes(program.items), [program.items]); /** Percent across the track of a source time (after any card at that moment unless noted). */ const percentAt = (sourceTime: number, includeCardsAtPoint = true) => (editedToProgramTime(sourceTimeToEditedTime(sourceTime, playableRanges), slots, { includeCardsAtPoint }) / @@ -274,6 +275,33 @@ export function Timeline() { /> ); })} + {/* Transitions: a diamond on each styled join, a wedge where the episode fades in or out. */} + {program.joins.map((join, j) => { + if (join.kind === "cut") return null; + const at = itemStarts[j + 1]; + const label = + join.kind === "crossfade" ? "Crossfade" : join.color === "white" ? "Dip to white" : "Dip to black"; + return ( +
+ ); + })} + {program.fadeIn && ( +
+ )} + {program.fadeOut && ( +
+ )} {scenes.slice(1).map((scene) => (
Date: Sat, 26 Sep 2026 01:43:16 +0530 Subject: [PATCH 21/36] docs: document transitions and why they keep program length Co-Authored-By: Claude Opus 5.5 --- claude.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/claude.md b/claude.md index 7ea0f9e..4313035 100644 --- a/claude.md +++ b/claude.md @@ -661,6 +661,19 @@ characters. The preview can't insert frames into a `