Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions frontend/src/components/videos/drawer/EditDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = (
<ClipPickerPanel
clipKind={target.clipKind}
index={target.index}
onCommit={close}
onCancel={close}
/>
);
} else if (target.kind === "narration") {
title = `Voiceover — ${sectionLabel(target.beatId).name}`;
body = <NarrationPanel beatId={target.beatId} onCommit={close} onCancel={close} />;
Expand Down
133 changes: 133 additions & 0 deletions frontend/src/components/videos/drawer/panels/ClipPickerPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="rounded border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground">
No clips defined in <code>spec.manifest</code>. Add entries to the manifest
(mapping aliases to <code>gdrive:</code> ids) before swapping.
</div>
);
}

return (
<div className="flex flex-col gap-3">
<div className="text-xs text-muted-foreground">
Pick a different clip from this program's manifest. The choice replaces the
slot's source — your existing trim values are preserved.
</div>
<div className="grid grid-cols-2 gap-3">
{aliases.map((alias) => {
const isCurrent = alias === currentAlias;
return (
<button
type="button"
key={alias}
onClick={() => swap(alias)}
onMouseEnter={() => setHovered(alias)}
onMouseLeave={() => setHovered((h) => (h === alias ? null : h))}
className={`flex flex-col gap-2 rounded border p-2 text-left transition-colors ${
isCurrent
? "border-primary bg-primary/5"
: "border-muted hover:border-primary"
}`}
>
<div className="relative">
<video
src={mediaUrlFor(alias)}
preload="metadata"
muted
// Briefly playing on hover would help users tell what's
// in the clip, but auto-play on hover trips Chrome's
// autoplay heuristics for muted previews — skip for
// now. The metadata-only preload still shows the first
// frame.
className="h-24 w-full rounded bg-black object-cover"
/>
{isCurrent && (
<span className="absolute right-1 top-1 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">
Current
</span>
)}
</div>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">@{alias}</code>
{hovered === alias && !isCurrent && (
<span className="text-[10px] text-muted-foreground">Click to swap</span>
)}
</button>
);
})}
</div>
<div className="mt-2 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded border px-3 py-1.5 text-sm"
>
Cancel
</button>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/components/videos/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
21 changes: 20 additions & 1 deletion frontend/src/components/videos/widgets/ClipSlotWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,26 @@ export function ClipSlotWidget({ beatId, clipKind, index }: Props) {
trimmed
</span>
)}
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors group-hover:text-foreground">
{/* 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. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
dispatch({
type: "OPEN_DRAWER",
target: { kind: "clip-picker", clipKind, beatId, index },
});
}}
className="ml-auto inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
title="Pick a different clip from this program's manifest"
>
↔ Swap
</button>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors group-hover:text-foreground">
✏ Edit trim
</span>
</header>
Expand Down
Loading