From 8174e5bb3c99a127bffdfac4c289ef78c6bb8d79 Mon Sep 17 00:00:00 2001 From: Jonathan Jackson Date: Tue, 19 May 2026 16:43:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(videos):=20swap-clip=20drawer=20=E2=80=94?= =?UTF-8?q?=20pick=20a=20different=20clip=20in=20a=20beat=20slot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend op (set-clip-asset) and the frontend op type already shipped. This wires up the UI: - New ClipPickerPanel renders every alias in spec.manifest as a thumbnail-tile grid. Current alias is sorted first with a "Current" badge. Click a different one → dispatch set-clip-asset → drawer closes. Existing trim values on the slot are preserved. - ClipSlotWidget header gets a "↔ Swap" button next to "✏ Edit trim". Both stop propagation so the card's catch-all onClick (which still opens trim) doesn't double-fire. - WidgetRef union extended with a clip-picker variant; EditDrawer routes to the new panel. Tested on chc/run-001 Field-footage clip 1: - Swap button opens picker (6 swap buttons total — 3 scene-clip + 3 product-beat slots) - 8 manifest aliases listed as 2-column grid with video thumbnails - Current alias has the "Current" badge - Clicking @mobile-learn swaps the slot's asset; card header updates from @field-group-around-woman to @mobile-learn Future work (not in this PR): - Browse the wider video library (more than the 8 manifest entries) and auto-add to manifest on pick. - Hover-preview that plays the muted clip. Co-Authored-By: Claude Opus 4.7 --- .../components/videos/drawer/EditDrawer.tsx | 11 ++ .../videos/drawer/panels/ClipPickerPanel.tsx | 133 ++++++++++++++++++ frontend/src/components/videos/types.ts | 1 + .../videos/widgets/ClipSlotWidget.tsx | 21 ++- 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/videos/drawer/panels/ClipPickerPanel.tsx diff --git a/frontend/src/components/videos/drawer/EditDrawer.tsx b/frontend/src/components/videos/drawer/EditDrawer.tsx index e698c94..cc18624 100644 --- a/frontend/src/components/videos/drawer/EditDrawer.tsx +++ b/frontend/src/components/videos/drawer/EditDrawer.tsx @@ -3,6 +3,7 @@ import { useBeatEditor } from "../BeatEditorContext"; import { sectionLabel } from "../sectionLabels"; import { DrawerShell } from "./DrawerShell"; import { ModalShell } from "./ModalShell"; +import { ClipPickerPanel } from "./panels/ClipPickerPanel"; import { ClipTrimPanel } from "./panels/ClipTrimPanel"; import { NarrationPanel } from "./panels/NarrationPanel"; import { StatPanel } from "./panels/StatPanel"; @@ -50,6 +51,16 @@ export function EditDrawer() { onCancel={close} /> ); + } else if (target.kind === "clip-picker") { + title = `Swap clip — ${clipSlotTitle(effectiveSpec, target.clipKind, target.index)}`; + body = ( + + ); } else if (target.kind === "narration") { title = `Voiceover — ${sectionLabel(target.beatId).name}`; body = ; diff --git a/frontend/src/components/videos/drawer/panels/ClipPickerPanel.tsx b/frontend/src/components/videos/drawer/panels/ClipPickerPanel.tsx new file mode 100644 index 0000000..71c4b27 --- /dev/null +++ b/frontend/src/components/videos/drawer/panels/ClipPickerPanel.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { useBeatEditor } from "../../BeatEditorContext"; + +interface Props { + clipKind: "scene-clip" | "product-beat"; + index: number; + onCommit: () => void; + onCancel: () => void; +} + +function aliasFromRef(r: string): string | null { + return r.startsWith("@") ? r.slice(1) : null; +} + +/** + * Swap the source clip in a beat slot. Lists every alias in + * spec.manifest as a clickable tile with a video thumbnail (served by + * the same serve_media endpoint the rendered explorer uses, so the + * preview frames come from the *same* mp4 the renderer will splice). + * + * Why aliases (not arbitrary library entries)? The renderer's + * applyManifestRefs() requires every `@alias` to resolve through the + * spec's manifest. Picking outside the manifest would mean adding a + * new entry, which is a bigger change than swap-among-existing. + * Adding "browse the wider library + add to manifest" is a natural + * follow-up — for now this panel covers the common case. + */ +export function ClipPickerPanel({ clipKind, index, onCommit, onCancel }: Props) { + const { effectiveSpec, workspaceSlug, programSlug, runId, dispatch } = useBeatEditor(); + + const slot = clipKind === "scene-clip" + ? effectiveSpec.scene?.clips[index] + : effectiveSpec.product?.beats[index]; + const currentRef = typeof slot === "string" ? slot : slot?.asset ?? ""; + const currentAlias = aliasFromRef(currentRef); + + const manifest = effectiveSpec.manifest ?? {}; + // Sort: current first, then alphabetical. The current slot's alias + // gets a "current" badge so the user knows which one they're + // looking at before picking a replacement. + const aliases = Object.keys(manifest).sort((a, b) => { + if (a === currentAlias) return -1; + if (b === currentAlias) return 1; + return a.localeCompare(b); + }); + + const prefix = (import.meta.env.BASE_URL ?? "/").replace(/\/$/, ""); + const mediaUrlFor = (alias: string) => + `${prefix}/api/w/${workspaceSlug}/videos/programs/${programSlug}/runs/${runId}/media/${alias}.mp4`; + + const [hovered, setHovered] = useState(null); + + const swap = (newAlias: string) => { + if (newAlias === currentAlias) { + onCancel(); + return; + } + dispatch({ + type: "APPEND_OP", + op: { op: "set-clip-asset", kind: clipKind, index, alias: newAlias }, + }); + onCommit(); + }; + + if (aliases.length === 0) { + return ( +
+ No clips defined in spec.manifest. Add entries to the manifest + (mapping aliases to gdrive: ids) before swapping. +
+ ); + } + + return ( +
+
+ Pick a different clip from this program's manifest. The choice replaces the + slot's source — your existing trim values are preserved. +
+
+ {aliases.map((alias) => { + const isCurrent = alias === currentAlias; + return ( + + ); + })} +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/videos/types.ts b/frontend/src/components/videos/types.ts index 0d8c8a7..78a060b 100644 --- a/frontend/src/components/videos/types.ts +++ b/frontend/src/components/videos/types.ts @@ -42,6 +42,7 @@ export type PendingChange = // What the drawer is currently editing. export type WidgetRef = | { kind: "clip-trim"; clipKind: "scene-clip" | "product-beat"; beatId: string; index: number } + | { kind: "clip-picker"; clipKind: "scene-clip" | "product-beat"; beatId: string; index: number } | { kind: "narration"; beatId: string } | { kind: "stat"; beatId: string; path: string }; diff --git a/frontend/src/components/videos/widgets/ClipSlotWidget.tsx b/frontend/src/components/videos/widgets/ClipSlotWidget.tsx index ba785f5..3568b82 100644 --- a/frontend/src/components/videos/widgets/ClipSlotWidget.tsx +++ b/frontend/src/components/videos/widgets/ClipSlotWidget.tsx @@ -91,7 +91,26 @@ export function ClipSlotWidget({ beatId, clipKind, index }: Props) { trimmed )} - + {/* Right-side affordances. "Swap" opens the clip picker for + this slot; the rest of the card (and the legacy "Edit trim" + hint) keeps opening the trim drawer. Both buttons stop + propagation so the card's catch-all onClick (which routes + to trim) doesn't fire alongside. */} + + ✏ Edit trim