mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +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,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseStoryboard } from "./parseStoryboard.js";
|
||||
import { setFrameStatus, setFrameVoiceover } from "./editStoryboard.js";
|
||||
|
||||
const DOC = `---
|
||||
message: Hi
|
||||
---
|
||||
|
||||
## Frame 1 — Hook
|
||||
- status: outline
|
||||
- voiceover: "old line"
|
||||
|
||||
Hook narrative.
|
||||
|
||||
## Frame 2 — Close
|
||||
- duration: 3s
|
||||
|
||||
Close narrative.
|
||||
`;
|
||||
|
||||
describe("setFrameVoiceover / setFrameStatus", () => {
|
||||
it("replaces an existing voiceover line in place, leaving other frames untouched", () => {
|
||||
const next = setFrameVoiceover(DOC, 1, "new line");
|
||||
const parsed = parseStoryboard(next);
|
||||
expect(parsed.frames[0].voiceover).toBe("new line");
|
||||
expect(parsed.frames[1].duration).toBe("3s");
|
||||
expect(next).toContain('- voiceover: "new line"');
|
||||
});
|
||||
|
||||
it("matches voiceover aliases (vo)", () => {
|
||||
const doc = "## Frame 1\n- vo: original\n\nBody.";
|
||||
const next = setFrameVoiceover(doc, 1, "updated");
|
||||
// The aliased key is preserved, only the value changes.
|
||||
expect(next).toContain("- vo: ");
|
||||
expect(parseStoryboard(next).frames[0].voiceover).toBe("updated");
|
||||
});
|
||||
|
||||
it("inserts the field after the heading when absent", () => {
|
||||
const next = setFrameVoiceover("## Frame 1 — Hook\n\nBody.", 1, "hi");
|
||||
const lines = next.split("\n");
|
||||
expect(lines[0]).toBe("## Frame 1 — Hook");
|
||||
expect(lines[1]).toBe('- voiceover: "hi"');
|
||||
expect(parseStoryboard(next).frames[0].voiceover).toBe("hi");
|
||||
});
|
||||
|
||||
it("advances status in place", () => {
|
||||
const next = setFrameStatus(DOC, 1, "built");
|
||||
expect(parseStoryboard(next).frames[0].status).toBe("built");
|
||||
expect(parseStoryboard(next).frames[1].status).toBe("outline");
|
||||
});
|
||||
|
||||
it("round-trips a voiceover containing double quotes (always wraps)", () => {
|
||||
const next = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, 'she said "hi"');
|
||||
expect(parseStoryboard(next).frames[0].voiceover).toBe('she said "hi"');
|
||||
});
|
||||
|
||||
it("round-trips an empty voiceover and a fully-quoted phrase", () => {
|
||||
const cleared = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, "");
|
||||
expect(parseStoryboard(cleared).frames[0].voiceover).toBe("");
|
||||
const quoted = setFrameVoiceover("## Frame 1\n- voiceover: x\n", 1, '"hello"');
|
||||
expect(parseStoryboard(quoted).frames[0].voiceover).toBe('"hello"');
|
||||
});
|
||||
|
||||
it("collapses newlines in a multi-line voiceover to a single line", () => {
|
||||
const next = setFrameVoiceover(
|
||||
"## Frame 1\n- voiceover: x\n\nNarrative.",
|
||||
1,
|
||||
"line one\nline two",
|
||||
);
|
||||
expect(parseStoryboard(next).frames[0].voiceover).toBe("line one line two");
|
||||
expect(parseStoryboard(next).frames[0].narrative).toBe("Narrative.");
|
||||
});
|
||||
|
||||
it("throws for an out-of-range frame", () => {
|
||||
expect(() => setFrameStatus(DOC, 9, "built")).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { FRAME_HEADING_RE, VOICEOVER_ALIASES } from "./parseStoryboard.js";
|
||||
import type { FrameStatus } from "./types.js";
|
||||
|
||||
// Re-exported for back-compat: the canonical list now lives in parseStoryboard.ts.
|
||||
export { VOICEOVER_ALIASES };
|
||||
|
||||
/**
|
||||
* Surgical writers for `STORYBOARD.md`.
|
||||
*
|
||||
* These update a single frame's metadata in place — preserving all other
|
||||
* content, formatting, comments, and non-frame sections — rather than
|
||||
* re-serializing the parsed manifest (which would be lossy). Used by the
|
||||
* storyboard frame-focus editor to persist `voiceover` / `status` edits.
|
||||
*
|
||||
* Frame detection and the voiceover aliases are imported from `parseStoryboard.ts`
|
||||
* so the read and write sides share one definition and can't drift.
|
||||
*/
|
||||
|
||||
const HEADING_LEVEL_RE = /^(#{1,6})[ \t]+/;
|
||||
/**
|
||||
* `- key:` prefix — captures the bullet, key, and `:`-separator (incl. surrounding
|
||||
* spaces) so the line can be rewritten as `<prefix><new value>`. Deliberately stops
|
||||
* at the separator and captures no value/EOL: the old value is overwritten wholesale,
|
||||
* so there's nothing to capture, and dropping the trailing `[ \t]*…(.*)$` removes the
|
||||
* overlapping-quantifier polynomial backtracking CodeQL flags (js/polynomial-redos).
|
||||
*/
|
||||
const META_LINE_RE = /^([ \t]*[-*][ \t]+)([A-Za-z_][\w-]*)([ \t]*:[ \t]*)/;
|
||||
|
||||
interface FrameBounds {
|
||||
/** 0-based line index of the frame heading. */
|
||||
start: number;
|
||||
/** 0-based line index just past the frame's content (exclusive). */
|
||||
end: number;
|
||||
/** Heading depth (`#` count) that opened the frame. */
|
||||
level: number;
|
||||
}
|
||||
|
||||
/** Locate every frame's line range, using the same boundary rules as the parser. */
|
||||
// fallow-ignore-next-line complexity
|
||||
function frameBounds(lines: string[]): FrameBounds[] {
|
||||
const bounds: FrameBounds[] = [];
|
||||
let current: FrameBounds | null = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const frameMatch = FRAME_HEADING_RE.exec(line);
|
||||
if (frameMatch) {
|
||||
if (current) current.end = i;
|
||||
current = { start: i, end: lines.length, level: (frameMatch[1] ?? "##").length };
|
||||
bounds.push(current);
|
||||
continue;
|
||||
}
|
||||
const heading = HEADING_LEVEL_RE.exec(line);
|
||||
if (current && heading && (heading[1] ?? "").length <= current.level) {
|
||||
current.end = i;
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function formatValue(value: string, quote: boolean): string {
|
||||
// Metadata is a single line: collapse every whitespace run (incl. newlines from a
|
||||
// multi-line textarea) to one space so the value can't split the `- key:` line and
|
||||
// corrupt the file. A single linear `\s+` avoids the `\s*\r?\n\s*` polynomial
|
||||
// backtracking CodeQL flags (js/polynomial-redos) on long all-space input.
|
||||
const clean = value.replace(/\s+/g, " ").trim();
|
||||
// Always wrap when quoting. The parser's stripQuotes removes exactly one outer
|
||||
// pair, so wrapping round-trips losslessly even for empty values or values that
|
||||
// themselves contain quotes (`"foo"` → `""foo""` → parses back to `"foo"`).
|
||||
return quote ? `"${clean}"` : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or insert) a metadata field on the frame at `frameIndex` (1-based).
|
||||
* Replaces an existing `- key: …` line (matching any alias) in place; otherwise
|
||||
* inserts a new line right after the frame heading.
|
||||
*
|
||||
* Throws when the frame doesn't exist, so a stale/raced index (e.g. the frame
|
||||
* was deleted on disk after render) surfaces as an error instead of a silent
|
||||
* no-op the UI would report as a successful save.
|
||||
*/
|
||||
export function setFrameField(
|
||||
source: string,
|
||||
frameIndex: number,
|
||||
key: string,
|
||||
value: string,
|
||||
opts: { aliases?: readonly string[]; quote?: boolean } = {},
|
||||
): string {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const target = frameBounds(lines)[frameIndex - 1];
|
||||
if (!target) throw new Error(`storyboard frame ${frameIndex} not found`);
|
||||
|
||||
const aliases = new Set([key, ...(opts.aliases ?? [])].map((k) => k.toLowerCase()));
|
||||
const formatted = formatValue(value, opts.quote ?? false);
|
||||
|
||||
for (let i = target.start + 1; i < target.end; i++) {
|
||||
const match = META_LINE_RE.exec(lines[i] ?? "");
|
||||
if (match && aliases.has((match[2] ?? "").toLowerCase())) {
|
||||
lines[i] = `${match[1]}${match[2]}${match[3]}${formatted}`;
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
lines.splice(target.start + 1, 0, `- ${key}: ${formatted}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Set the voiceover (guide) line for a frame, matching any voiceover alias. */
|
||||
export function setFrameVoiceover(source: string, frameIndex: number, value: string): string {
|
||||
return setFrameField(source, frameIndex, "voiceover", value, {
|
||||
aliases: VOICEOVER_ALIASES,
|
||||
quote: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Set the lifecycle status for a frame. */
|
||||
export function setFrameStatus(source: string, frameIndex: number, status: FrameStatus): string {
|
||||
return setFrameField(source, frameIndex, "status", status);
|
||||
}
|
||||
@@ -10,3 +10,9 @@ export {
|
||||
type StoryboardManifest,
|
||||
} from "./types.js";
|
||||
export { parseStoryboard } from "./parseStoryboard.js";
|
||||
export {
|
||||
setFrameField,
|
||||
setFrameVoiceover,
|
||||
setFrameStatus,
|
||||
VOICEOVER_ALIASES,
|
||||
} from "./editStoryboard.js";
|
||||
|
||||
@@ -46,7 +46,7 @@ export function parseStoryboard(source: string): StoryboardManifest {
|
||||
// Detection-only (ends at the keyword) — the title is sliced off in code. A single
|
||||
// `[ \t]+` before the required keyword stays linear; avoids the polynomial backtracking
|
||||
// a trailing `[\s…]*(.*)$` would add on tab-heavy input (CodeQL js/polynomial-redos).
|
||||
const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
|
||||
export const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
|
||||
/** Leading separators between the frame keyword and its title text. */
|
||||
const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
|
||||
/** Any markdown heading; captures the `#` run so section depth can be compared. */
|
||||
@@ -61,8 +61,13 @@ const DURATION_NUM_RE = /(\d+(?:\.\d+)?)/;
|
||||
const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]);
|
||||
/** Metadata keys that all map to the one-line scene description. */
|
||||
const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]);
|
||||
/** Metadata keys that all map to the voiceover/narration line. */
|
||||
const VOICEOVER_KEYS = new Set(["voiceover", "vo", "voice_over", "narration"]);
|
||||
/**
|
||||
* Aliases that all map to the voiceover/narration line. The single source of
|
||||
* truth — `editStoryboard.ts` imports this so the read and write sides can't
|
||||
* drift (one would silently fail to match the other's field name).
|
||||
*/
|
||||
export const VOICEOVER_ALIASES = ["voiceover", "vo", "voice_over", "narration"] as const;
|
||||
const VOICEOVER_KEYS = new Set<string>(VOICEOVER_ALIASES);
|
||||
|
||||
interface FrontmatterResult {
|
||||
globals: StoryboardGlobals;
|
||||
|
||||
@@ -498,7 +498,10 @@ export function StudioApp() {
|
||||
/>
|
||||
)}
|
||||
{viewModeValue.viewMode === "storyboard" && (
|
||||
<StoryboardView projectId={projectId} />
|
||||
<StoryboardView
|
||||
projectId={projectId}
|
||||
onSelectComposition={handleSelectComposition}
|
||||
/>
|
||||
)}
|
||||
{/* Timeline stage stays mounted (just hidden) in storyboard mode,
|
||||
so preview/player/gesture/render state survives the toggle. */}
|
||||
|
||||
@@ -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