mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
feat(studio): storyboard frame focus + voiceover iteration (#1532)
Fifth PR in the Studio storyboarding stack. Click a contact-sheet tile to open a full-area focus on that frame. - StoryboardFrameFocus: large poster, prev/next nav, full narrative, and an editable voiceover *guide* (textarea) saved back to STORYBOARD.md. Status can be advanced outline → built → animated inline. - "Open in Preview" jumps to the timeline focused on the frame's sub-composition (setActiveCompPath + view-mode timeline). - core/storyboard: setFrameField / setFrameVoiceover / setFrameStatus — surgical in-place writers that update one frame's metadata without re-serializing (markdown stays canonical). Tested. - Extract shared FramePoster (used by tile + focus); tiles are now buttons that open focus. Voiceover here is the editable guide; SCRIPT.md remains the locked narration that drives TTS. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
29809069c8
commit
c8fd16f2d3
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
||||
|
||||
export interface FramePosterProps {
|
||||
projectId: string;
|
||||
/** Project-relative path to the frame's HTML sub-composition. */
|
||||
src: string;
|
||||
/** Time (seconds) to seek to for the poster. */
|
||||
seconds: number;
|
||||
title: string;
|
||||
/** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
|
||||
fit?: "cover" | "contain";
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-rendered poster for a frame. The thumbnail route seeks the composition
|
||||
* by time (at its real fps) and caches the result, so there's no live iframe,
|
||||
* no postMessage seek, and no client-side fps assumption. Shared by the
|
||||
* contact-sheet tile and the frame-focus view.
|
||||
*/
|
||||
export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
|
||||
Preview unavailable
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const url = buildCompositionThumbnailUrl({
|
||||
previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
|
||||
seekTime: seconds,
|
||||
duration: 0,
|
||||
origin: window.location.origin,
|
||||
});
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className={`h-full w-full ${fit === "contain" ? "object-contain" : "object-cover"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Time (seconds) to show a frame at — past the intro so the key moment is visible. */
|
||||
export function posterTime(frame: { poster?: number; durationSeconds?: number }): number {
|
||||
if (frame.poster != null) return frame.poster;
|
||||
if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
|
||||
return 1.5;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard";
|
||||
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||
import { useViewMode } from "../../contexts/ViewModeContext";
|
||||
import { FramePoster, posterTime } from "./FramePoster";
|
||||
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
|
||||
|
||||
export interface StoryboardFrameFocusProps {
|
||||
projectId: string;
|
||||
/** Path to STORYBOARD.md (edits are written here). */
|
||||
storyboardPath: string;
|
||||
frame: StoryboardFrameView;
|
||||
frameCount: number;
|
||||
onBack: () => void;
|
||||
onNavigate: (delta: number) => void;
|
||||
/** Re-parse the manifest after an edit is saved. */
|
||||
onSaved: () => void;
|
||||
/** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
|
||||
onSelectComposition: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-area focus on a single frame: large poster, editable voiceover guide,
|
||||
* status advancement, full narrative, and a jump into the live preview. Edits
|
||||
* are written back to STORYBOARD.md in place (markdown stays canonical).
|
||||
*
|
||||
* Mounted with a `key` per frame, so `draft` initializes from the frame and a
|
||||
* save-triggered reload never clobbers in-progress typing.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StoryboardFrameFocus({
|
||||
projectId,
|
||||
storyboardPath,
|
||||
frame,
|
||||
frameCount,
|
||||
onBack,
|
||||
onNavigate,
|
||||
onSaved,
|
||||
onSelectComposition,
|
||||
}: StoryboardFrameFocusProps) {
|
||||
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
||||
const { setViewMode } = useViewMode();
|
||||
const [draft, setDraft] = useState(frame.voiceover ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applyEdit = useCallback(
|
||||
async (edit: (source: string) => string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const source = await readProjectFile(storyboardPath);
|
||||
await writeProjectFile(storyboardPath, edit(source));
|
||||
onSaved();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "failed to save");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[readProjectFile, writeProjectFile, storyboardPath, onSaved],
|
||||
);
|
||||
|
||||
const title = frame.title ?? `Frame ${frame.index}`;
|
||||
const dirty = draft !== (frame.voiceover ?? "");
|
||||
const canOpenPreview = frame.srcExists && Boolean(frame.src);
|
||||
|
||||
// Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty.
|
||||
const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
|
||||
const handleBack = () => {
|
||||
if (confirmLeave()) onBack();
|
||||
};
|
||||
const handleNavigate = (delta: number) => {
|
||||
if (confirmLeave()) onNavigate(delta);
|
||||
};
|
||||
|
||||
const openInPreview = () => {
|
||||
if (frame.src) onSelectComposition(frame.src);
|
||||
setViewMode("timeline");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
||||
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800"
|
||||
>
|
||||
← Board
|
||||
</button>
|
||||
<span className="text-sm font-medium text-neutral-200">
|
||||
Frame {frame.number ?? frame.index} — {title}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<NavButton
|
||||
label="‹ Prev"
|
||||
disabled={frame.index <= 1}
|
||||
onClick={() => handleNavigate(-1)}
|
||||
/>
|
||||
<NavButton
|
||||
label="Next ›"
|
||||
disabled={frame.index >= frameCount}
|
||||
onClick={() => handleNavigate(1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex w-3/5 min-w-0 items-center justify-center bg-neutral-900/40 p-8">
|
||||
<div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
|
||||
{canOpenPreview && frame.src ? (
|
||||
<FramePoster
|
||||
projectId={projectId}
|
||||
src={frame.src}
|
||||
seconds={posterTime(frame)}
|
||||
title={title}
|
||||
fit="contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
|
||||
{frame.status === "outline" ? "Not built yet" : "No preview"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-2/5 min-w-0 space-y-6 overflow-auto border-l border-neutral-800 px-6 py-5">
|
||||
<StatusRow
|
||||
status={frame.status}
|
||||
busy={busy}
|
||||
onSet={(s) => applyEdit((src) => setFrameStatus(src, frame.index, s))}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
|
||||
{frame.duration && <span>Duration {frame.duration}</span>}
|
||||
{frame.transitionIn && <span>Transition {frame.transitionIn}</span>}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyEdit((src) => setFrameVoiceover(src, frame.index, draft))}
|
||||
disabled={!dirty || busy}
|
||||
className="rounded bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="What the narrator says over this frame…"
|
||||
className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-neutral-600">
|
||||
A draft guide. SCRIPT.md locks the final narration that drives TTS.
|
||||
</p>
|
||||
{error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
|
||||
</section>
|
||||
|
||||
{frame.narrative && (
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Narrative
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap text-sm text-neutral-300">{frame.narrative}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openInPreview}
|
||||
disabled={!canOpenPreview}
|
||||
className="rounded border border-neutral-700 px-3 py-1.5 text-xs font-medium text-neutral-200 hover:bg-neutral-800 disabled:opacity-40"
|
||||
>
|
||||
Open in Preview →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 disabled:opacity-30"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
status,
|
||||
busy,
|
||||
onSet,
|
||||
}: {
|
||||
status: FrameStatus;
|
||||
busy: boolean;
|
||||
onSet: (next: FrameStatus) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
||||
Status
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5">
|
||||
{FRAME_STATUS_ORDER.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
title={FRAME_STATUS_META[option].tooltip}
|
||||
onClick={() => onSet(option)}
|
||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
status === option
|
||||
? "bg-neutral-700 text-neutral-100"
|
||||
: "text-neutral-400 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{FRAME_STATUS_META[option].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
||||
import { FramePoster, posterTime } from "./FramePoster";
|
||||
import { FRAME_STATUS_META } from "./frameStatus";
|
||||
|
||||
export interface StoryboardFrameTileProps {
|
||||
projectId: string;
|
||||
frame: StoryboardFrameView;
|
||||
/** Open this frame in the full-area focus view. */
|
||||
onOpen: (index: number) => void;
|
||||
}
|
||||
|
||||
const TILE_WIDTH = 360;
|
||||
|
||||
/** Time (seconds) to show a tile at — past the intro so the key moment is visible. */
|
||||
function posterTime(frame: StoryboardFrameView): number {
|
||||
if (frame.poster != null) return frame.poster;
|
||||
if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
|
||||
return 1.5;
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
@@ -32,9 +26,9 @@ function placeholderMessage(frame: StoryboardFrameView): string {
|
||||
return "No preview";
|
||||
}
|
||||
|
||||
/** A single contact-sheet tile: poster preview + its metadata. */
|
||||
/** A single contact-sheet tile: poster preview + its metadata. Click to focus. */
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTileProps) {
|
||||
export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFrameTileProps) {
|
||||
const meta = FRAME_STATUS_META[frame.status];
|
||||
const renderable = frame.srcExists && frame.status !== "outline";
|
||||
const title = frame.title ?? `Frame ${frame.index}`;
|
||||
@@ -42,7 +36,11 @@ export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTilePro
|
||||
|
||||
return (
|
||||
<article style={{ width: TILE_WIDTH }}>
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(frame.index)}
|
||||
className="group relative block aspect-video w-full overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900 text-left transition-colors hover:border-neutral-600"
|
||||
>
|
||||
<div className="absolute left-2 top-2 z-10 flex h-6 min-w-6 items-center justify-center rounded-full bg-black/70 px-1.5 text-xs font-semibold text-neutral-100">
|
||||
{frame.number ?? frame.index}
|
||||
</div>
|
||||
@@ -56,7 +54,7 @@ export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTilePro
|
||||
) : (
|
||||
<FrameTilePlaceholder frame={frame} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="mt-2 flex items-start justify-between gap-2">
|
||||
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
|
||||
@@ -81,48 +79,6 @@ export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTilePro
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-rendered poster for a frame. The thumbnail route seeks the composition
|
||||
* by time (at its real fps) and caches the result, so there's no live iframe,
|
||||
* no postMessage seek, and no client-side fps assumption.
|
||||
*/
|
||||
function FramePoster({
|
||||
projectId,
|
||||
src,
|
||||
seconds,
|
||||
title,
|
||||
}: {
|
||||
projectId: string;
|
||||
src: string;
|
||||
seconds: number;
|
||||
title: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
|
||||
Preview unavailable
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const url = buildCompositionThumbnailUrl({
|
||||
previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
|
||||
seekTime: seconds,
|
||||
duration: 0,
|
||||
origin: window.location.origin,
|
||||
});
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 border border-dashed border-neutral-700 bg-neutral-950 text-center">
|
||||
|
||||
@@ -4,10 +4,12 @@ import { StoryboardFrameTile } from "./StoryboardFrameTile";
|
||||
export interface StoryboardGridProps {
|
||||
projectId: string;
|
||||
frames: StoryboardFrameView[];
|
||||
/** Open a frame in the full-area focus view. */
|
||||
onOpenFrame: (index: number) => void;
|
||||
}
|
||||
|
||||
/** The contact sheet: ordered frame tiles in a responsive grid. */
|
||||
export function StoryboardGrid({ projectId, frames }: StoryboardGridProps) {
|
||||
export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGridProps) {
|
||||
if (frames.length === 0) {
|
||||
return (
|
||||
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
|
||||
@@ -19,7 +21,12 @@ export function StoryboardGrid({ projectId, frames }: StoryboardGridProps) {
|
||||
return (
|
||||
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-8">
|
||||
{frames.map((frame) => (
|
||||
<StoryboardFrameTile key={frame.index} projectId={projectId} frame={frame} />
|
||||
<StoryboardFrameTile
|
||||
key={frame.index}
|
||||
projectId={projectId}
|
||||
frame={frame}
|
||||
onOpen={onOpenFrame}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StoryboardGrid } from "./StoryboardGrid";
|
||||
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
|
||||
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
||||
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
|
||||
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
|
||||
|
||||
type SubView = "board" | "source";
|
||||
|
||||
@@ -13,12 +14,25 @@ export interface StoryboardLoadedProps {
|
||||
data: StoryboardResponse;
|
||||
/** Re-fetch the manifest after a source edit is saved. */
|
||||
reload: () => void;
|
||||
/** Select a composition in the timeline (used by "Open in Preview"). */
|
||||
onSelectComposition: (path: string) => void;
|
||||
}
|
||||
|
||||
/** A storyboard that exists on disk: Board (contact sheet) ↔ Source (markdown editor). */
|
||||
export function StoryboardLoaded({ projectId, data, reload }: StoryboardLoadedProps) {
|
||||
function clampIndex(index: number, count: number): number {
|
||||
return Math.max(1, Math.min(count, index));
|
||||
}
|
||||
|
||||
/** A storyboard that exists on disk: Board (contact sheet) ↔ Source ↔ frame focus. */
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StoryboardLoaded({
|
||||
projectId,
|
||||
data,
|
||||
reload,
|
||||
onSelectComposition,
|
||||
}: StoryboardLoadedProps) {
|
||||
const [subView, setSubView] = useState<SubView>("board");
|
||||
const [sourceDirty, setSourceDirty] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||
const sourceFiles = useMemo<SourceFile[]>(() => {
|
||||
const files: SourceFile[] = [{ path: data.path, label: data.path }];
|
||||
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
|
||||
@@ -39,6 +53,27 @@ export function StoryboardLoaded({ projectId, data, reload }: StoryboardLoadedPr
|
||||
setSubView(next);
|
||||
};
|
||||
|
||||
const focusedFrame =
|
||||
focusedIndex != null ? (data.frames.find((f) => f.index === focusedIndex) ?? null) : null;
|
||||
|
||||
if (focusedFrame) {
|
||||
return (
|
||||
<StoryboardFrameFocus
|
||||
key={focusedFrame.index}
|
||||
projectId={projectId}
|
||||
storyboardPath={data.path}
|
||||
frame={focusedFrame}
|
||||
frameCount={data.frames.length}
|
||||
onBack={() => setFocusedIndex(null)}
|
||||
onNavigate={(delta) =>
|
||||
setFocusedIndex(clampIndex(focusedFrame.index + delta, data.frames.length))
|
||||
}
|
||||
onSaved={reload}
|
||||
onSelectComposition={onSelectComposition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
||||
<div className="flex items-center border-b border-neutral-800 px-4 py-2">
|
||||
@@ -51,7 +86,11 @@ export function StoryboardLoaded({ projectId, data, reload }: StoryboardLoadedPr
|
||||
<div className="mt-5">
|
||||
<StoryboardStatusLegend />
|
||||
</div>
|
||||
<StoryboardGrid projectId={projectId} frames={data.frames} />
|
||||
<StoryboardGrid
|
||||
projectId={projectId}
|
||||
frames={data.frames}
|
||||
onOpenFrame={setFocusedIndex}
|
||||
/>
|
||||
{data.script && <StoryboardScriptPanel script={data.script} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { StoryboardLoaded } from "./StoryboardLoaded";
|
||||
|
||||
export interface StoryboardViewProps {
|
||||
projectId: string;
|
||||
/** Select a composition in the timeline (used by the frame focus "Open in Preview"). */
|
||||
onSelectComposition: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -12,7 +14,7 @@ export interface StoryboardViewProps {
|
||||
* {@link StoryboardLoaded} owns the Board ↔ Source experience.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StoryboardView({ projectId }: StoryboardViewProps) {
|
||||
export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
|
||||
const { data, loading, error, reload } = useStoryboard(projectId);
|
||||
|
||||
if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
|
||||
@@ -32,7 +34,14 @@ export function StoryboardView({ projectId }: StoryboardViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
return <StoryboardLoaded projectId={projectId} data={data} reload={reload} />;
|
||||
return (
|
||||
<StoryboardLoaded
|
||||
projectId={projectId}
|
||||
data={data}
|
||||
reload={reload}
|
||||
onSelectComposition={onSelectComposition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StoryboardFrame({ children }: { children: ReactNode }) {
|
||||
|
||||
Reference in New Issue
Block a user