Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
3f0fe88
feat: give split operations a scene title, source and group
amide-init Sep 25, 2026
0423270
feat(client): undo history as steps, not single operations
amide-init Sep 25, 2026
551a55f
feat(client): derive scenes from split operations
amide-init Sep 25, 2026
e270cda
feat(client): Scenes panel, scene strip and split shortcuts
amide-init Sep 25, 2026
db323ea
Merge pull request #39 from amide-init/feat/scenes
amide-init Sep 25, 2026
3def289
fix(render): stop dropping footage after every cut on ffmpeg 9
amide-init Sep 25, 2026
036b493
fix(render): pad segments so a short one can't end the export early
amide-init Sep 25, 2026
c69354f
feat: add a card operation for title cards
amide-init Sep 25, 2026
b5fd6a4
feat: program timeline model for title cards
amide-init Sep 25, 2026
9bac2e2
feat(server): lay out title card text and write it as ASS
amide-init Sep 25, 2026
2292307
feat(server): render title cards into exports
amide-init Sep 25, 2026
2997c18
feat(server): chapters and transcript export on program time
amide-init Sep 25, 2026
7b786f9
feat(client): preview title cards in the player
amide-init Sep 25, 2026
f795827
feat(client): show title cards on the timeline
amide-init Sep 25, 2026
4674424
feat(client): add and edit title cards from the Scenes panel
amide-init Sep 25, 2026
d7cac42
docs: document scenes, title cards and the ffmpeg 9 xfade rules
amide-init Sep 25, 2026
900c61b
Merge pull request #40 from amide-init/fix/xfade-frame-rate
amide-init Sep 25, 2026
8ed1192
Merge pull request #41 from amide-init/feat/title-cards
amide-init Sep 25, 2026
9cff45d
feat: add a transition operation
amide-init Sep 25, 2026
357a341
feat: place transitions in the program model
amide-init Sep 25, 2026
2229e2d
feat(server): render transitions without changing the program's length
amide-init Sep 25, 2026
e088d68
feat(client): preview transitions over the player
amide-init Sep 25, 2026
a446127
feat(client): pick transitions per scene and show them on the timeline
amide-init Sep 25, 2026
ade1bad
docs: document transitions and why they keep program length
amide-init Sep 25, 2026
c7b5ca3
Merge pull request #42 from amide-init/feat/transitions
amide-init Sep 25, 2026
673a85a
feat(server): a media library for B-roll
amide-init Sep 25, 2026
b827b1d
feat: add an overlay operation for B-roll
amide-init Sep 25, 2026
83662bb
feat: place B-roll on the program timeline
amide-init Sep 25, 2026
c2ff158
feat(server): composite B-roll into exports
amide-init Sep 25, 2026
accb651
feat(client): Media panel to upload and place B-roll
amide-init Sep 25, 2026
af27d7f
feat(client): preview B-roll in the player and on the timeline
amide-init Sep 25, 2026
037fd96
docs: document the media library, B-roll and its endpoints
amide-init Sep 25, 2026
1ba69d0
Merge pull request #43 from amide-init/feat/broll
amide-init Sep 25, 2026
0671430
feat(server): detect shot changes in the background
amide-init Sep 25, 2026
cf486aa
feat(server): suggest scenes with AI, for review
amide-init Sep 25, 2026
ba3130c
feat(client): review and apply scene suggestions
amide-init Sep 25, 2026
5068467
docs: document scenes, title cards, transitions and B-roll
amide-init Sep 25, 2026
e81b0aa
Merge pull request #44 from amide-init/feat/scene-suggestions
amide-init Sep 25, 2026
aaeb6d7
feat(server): per-scene frame padding
amide-init Sep 27, 2026
4cf17d2
feat(client): per-scene padding picker and preview
amide-init Sep 27, 2026
93ea0f6
feat(client): press / in the transcript to start a scene
amide-init Sep 27, 2026
ef69351
docs: scene padding and slash split
amide-init Sep 27, 2026
1195921
Merge pull request #45 from amide-init/feat/scene-padding
amide-init Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ For podcasts:
transcript selection; render 9:16 / 1:1 / 16:9 with a positionable crop
and big word-by-word captions.

Shaping the episode:

- **Scenes** — split at the playhead (**S**) or before any sentence, then
name, cut or merge whole scenes. **Suggest scenes** proposes topic
changes for review, landed on detected camera cuts; or build scenes
from your chapters.
- **Title cards** — intro, chapter, quote and outro cards between scenes,
previewed live and rendered into the export.
- **Transitions** — dip to black/white, crossfade into a card, and fades
in and out of the episode, without shifting anything's timing.
- **B-roll** — upload images and clips, then cover a transcript selection
full screen or picture-in-picture.

Every edit, including bulk ones like "remove all filler words" or applying
suggested scenes, is a single undo step (**⌘Z** / **⇧⌘Z**).

AI models only ever return text and sentence picks, never timestamps or
edits: times always come from the transcript's own word timestamps, and
every response is validated before use. See the
Expand Down
93 changes: 93 additions & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,91 @@ 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 `<video>`, so
`useCardPlayback.ts` pauses the video where a card plays, shows
`CardPreview` (same layout percentages, `lib/cards/layout.ts`) on its own
clock, then resumes.

A `transition` operation styles the join into the scene starting at its
`at` (both joins around that scene's card, if it has one): dip to black,
dip to white, or crossfade (only with a card -- between two stretches of
footage that follow each other in the source it shows nothing, so it
falls back to a cut). At 0 it fades the episode in, at the end it fades
it out. **Transitions never change the program's length**, so captions
and chapters need no adjusting: a dip fades each side's own edge
(`fade`/`afade`, audio capped at 0.3s next to speech) and keeps the
usual join, and a crossfade's overlap is added to the card's rendered
length (`plan.ts#layoutProgram`). The preview draws them with
`TransitionOverlay` from the pure `transition-look.ts`, on its own
animation-frame loop; audio fades aren't previewed.

**Scene padding**: a `pad` operation (`at` = a scene start, `size`
2-25% per side, `color` '#RRGGBB') shrinks that scene's footage and
centres it on a solid border; the output size never changes. Scenes are
matched and the latest pad wins like transitions
(`program.ts#placePads`); a padded scene's edges become ordinary cut
joins in the program, so its length doesn't change either. Rendered per
segment after the grade (`plan.ts#buildPadFilter`: scale, then `pad`
back to the source size, so xfade inputs still match); card, B-roll,
caption and logo layers are unaffected, and clips skip it. Merging a
scene removes its padding. The preview (`PaddingLayer`) scales the
`<video>` over the border color on its own animation-frame loop.

In the transcript, clicking a word sets a cursor before it and `/`
starts a new scene there (`useEditorShortcuts`, via `gapBefore` +
`splitAt`); with no cursor `/` splits at the playhead like `S`.

**Scene suggestions** are explicit buttons that only ever propose:
"Suggest scenes" (gpt-4o-mini, sentence indices and titles only --
`lib/ai/scenes.ts`, validated by `lib/scenes/suggest.ts`: whole
sentences, the first scene at 0, at least 45s apart, boundaries in the
silence before a sentence) and "From chapters" (client-side, no AI).
Shot-change detection (`lib/ffmpeg/shots.ts`, a `shots` TranscriptionJob
storing its times in `resultJson`) scores frames at 320px wide; a
suggestion within 1.5s of a cut -- and not inside a word -- moves onto
it. The user reviews the list and Apply adds the kept splits (and
optional numbered chapter cards) as one undo step, renaming a split
already within 1s instead of adding a sliver.

**B-roll**: `media` assets (many per project, stored under a unique
prefix, with name/size/length columns) placed by `overlay` operations:
an asset shown from `start` to `end` in source time (so it follows cuts),
full screen (scaled to fill, cropped) or picture-in-picture (32% wide,
4% inset, a chosen corner). Placed on program time like captions
(`program.ts#placeOverlays`), composited as extra ffmpeg inputs after the
join and grade, before any clip reframe, card text, captions and logo --
an image looped for its slot, a video trimmed from its offset holding its
last frame. B-roll audio is never used. It plays in clips too.

**ffmpeg 9 xfade gotchas** (`plan.ts`, measured on 9.0.2): every segment
must be pinned to the source's frame rate with `fps=` (without it xfade
drops the start of each later segment), must not get a `settb` after
that (brings the bug back), and every segment but the last is padded
with `tpad` clone frames (a segment a frame short of its computed length
otherwise ends the whole chain at that join).

---

# 13. Architecture (local-first)
Expand Down Expand Up @@ -901,6 +986,14 @@ GET /api/projects/:id/clips
POST /api/projects/:id/clips/highlights (GPT-5.6 Luna picks; replaces earlier AI picks)
POST /api/projects/:id/clips PATCH/DELETE /api/projects/:id/clips/:clipId
(render a clip: POST /api/projects/:id/render with {clipId})

GET /api/projects/:id/media (media library: images and clips for B-roll)
POST /api/projects/:id/media?filename= (raw body, image/png|jpeg|webp or video/*; probed, refused if unreadable)
GET /api/projects/:id/media/:assetId/file DELETE /api/projects/:id/media/:assetId (409 while B-roll uses it)

POST /api/projects/:id/scenes/shots (202 + jobId; background shot-change detection)
GET /api/projects/:id/scenes/shots (latest detection) GET /api/projects/:id/scenes/shots/:jobId
POST /api/projects/:id/scenes/suggest (gpt-4o-mini scene suggestions for review; never applied server-side)
```

(No `POST /api/projects/:id/ai/edit` — the free-text AI command bar this
Expand Down
16 changes: 7 additions & 9 deletions client/src/components/editor/AiToolsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [],
Expand All @@ -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([]);
};

Expand All @@ -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);
};

Expand Down
160 changes: 160 additions & 0 deletions client/src/components/editor/CardEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"use client";

import { useEffect, useState } from "react";
import { usePlayerStore } from "@/stores/player-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { CARD_BACKGROUNDS } from "@/lib/cards/layout";
import {
CARD_MAX_SECONDS,
CARD_MIN_SECONDS,
CARD_SUBTITLE_MAX,
CARD_TEMPLATES,
CARD_TITLE_MAX,
type CardOperation,
type CardTemplate,
} from "@/types/edit-operation";

const TEMPLATE_LABELS: Record<CardTemplate, string> = {
title: "Title",
chapter: "Chapter",
quote: "Quote",
outro: "Outro",
};

const SUBTITLE_PLACEHOLDER: Record<CardTemplate, string> = {
title: "Subtitle (optional)",
chapter: "Label above, e.g. Part 2",
quote: "Who said it (optional)",
outro: "e.g. Subscribe for more",
};

/** Card text goes into an ASS file at export: no override-tag characters or line breaks (the API refuses them too). */
function cleanCardText(text: string): string {
return Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0);
return code >= 0x20 && code !== 0x7f && !"{}\\".includes(ch);
})
.join("");
}

/**
* Edits one title card. Changes stay in a local draft, previewed live over
* the player (player store's cardDraft), and are saved as a single
* operation replacement -- one undo step -- when the editor is closed.
*/
export function CardEditor({
card,
onSave,
onDelete,
onClose,
}: {
card: CardOperation;
onSave: (next: CardOperation) => void;
onDelete: () => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(card);
const setCardDraft = usePlayerStore((s) => s.setCardDraft);

useEffect(() => {
setCardDraft(draft);
}, [draft, setCardDraft]);
useEffect(() => () => setCardDraft(null), [setCardDraft]);

const update = (patch: Partial<CardOperation>) => setDraft((d) => ({ ...d, ...patch }));
const title = draft.title.trim();
const changed = JSON.stringify(draft) !== JSON.stringify(card);

const done = () => {
if (changed && title) {
const subtitle = draft.subtitle?.trim();
onSave({ ...draft, title, subtitle: subtitle || undefined });
}
onClose();
};

return (
<div className="flex flex-col gap-2.5 rounded-md border border-border bg-muted/40 p-2.5">
<div className="grid grid-cols-4 gap-1">
{CARD_TEMPLATES.map((template) => (
<button
key={template}
type="button"
onClick={() => update({ template })}
className={`rounded border px-1 py-1 text-[0.7rem] ${
draft.template === template
? "border-primary bg-primary/15 text-primary"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{TEMPLATE_LABELS[template]}
</button>
))}
</div>
<Input
autoFocus
value={draft.title}
maxLength={CARD_TITLE_MAX}
placeholder="Title"
aria-label="Card title"
onChange={(e) => update({ title: cleanCardText(e.target.value) })}
onKeyDown={(e) => e.key === "Enter" && done()}
className="h-8 text-xs"
/>
<Input
value={draft.subtitle ?? ""}
maxLength={CARD_SUBTITLE_MAX}
placeholder={SUBTITLE_PLACEHOLDER[draft.template]}
aria-label="Card subtitle"
onChange={(e) => update({ subtitle: cleanCardText(e.target.value) })}
onKeyDown={(e) => e.key === "Enter" && done()}
className="h-8 text-xs"
/>
<label className="flex items-center gap-2 text-muted-foreground">
<span className="w-14 shrink-0">Duration</span>
<input
type="range"
min={CARD_MIN_SECONDS}
max={CARD_MAX_SECONDS}
step={0.5}
value={draft.duration}
onChange={(e) => update({ duration: Number(e.target.value) })}
className="min-w-0 flex-1 accent-primary"
/>
<span className="w-8 text-right font-mono tabular-nums">{draft.duration}s</span>
</label>
<div className="flex items-center gap-1.5">
<span className="w-14 shrink-0 text-muted-foreground">Color</span>
{CARD_BACKGROUNDS.map((color) => (
<button
key={color}
type="button"
onClick={() => update({ background: color })}
title={color}
className={`size-5 rounded-full border ${
draft.background.toUpperCase() === color ? "ring-2 ring-primary ring-offset-1 ring-offset-card" : "border-border"
}`}
style={{ backgroundColor: color }}
/>
))}
<input
type="color"
value={draft.background}
onChange={(e) => update({ background: e.target.value.toUpperCase() })}
aria-label="Custom card color"
className="size-5 cursor-pointer rounded-full border border-border bg-transparent p-0"
/>
</div>
<div className="flex justify-between">
<Button variant="ghost" size="xs" className="text-destructive hover:text-destructive" onClick={onDelete}>
Delete card
</Button>
<Button size="xs" onClick={done} disabled={!title}>
Done
</Button>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions client/src/components/editor/EditorHydrator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useProjectStore } from "@/stores/project-store";
import { useTranscriptStore } from "@/stores/transcript-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useCaptionStyleStore } from "@/stores/caption-style-store";
import { useMediaStore } from "@/stores/media-store";
import { EditorLayout } from "@/components/editor/EditorLayout";
import type { Transcript } from "@/types/transcript";
import type { EditOperation } from "@/types/edit-operation";
Expand Down Expand Up @@ -44,12 +45,14 @@ export function EditorHydrator({
const setTranscript = useTranscriptStore((s) => s.setTranscript);
const hydrateTimeline = useTimelineStore((s) => s.hydrate);
const hydrateCaptionStyle = useCaptionStyleStore((s) => s.hydrate);
const loadMedia = useMediaStore((s) => s.load);

useEffect(() => {
hydrateProject(project);
setTranscript(transcript);
hydrateTimeline(project.id, operations);
hydrateCaptionStyle(project.id, project.burnInCaptions, project.captionStyle);
void loadMedia(project.id);
// Only re-hydrate when navigating to a different project.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.id]);
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/editor/EditorLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<div className="flex h-screen flex-col gap-3 p-3">
Expand Down
Loading
Loading