mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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,
|
type StoryboardManifest,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
export { parseStoryboard } from "./parseStoryboard.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
|
// 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
|
// `[ \t]+` before the required keyword stays linear; avoids the polynomial backtracking
|
||||||
// a trailing `[\s…]*(.*)$` would add on tab-heavy input (CodeQL js/polynomial-redos).
|
// 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. */
|
/** Leading separators between the frame keyword and its title text. */
|
||||||
const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
|
const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
|
||||||
/** Any markdown heading; captures the `#` run so section depth can be compared. */
|
/** 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"]);
|
const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]);
|
||||||
/** Metadata keys that all map to the one-line scene description. */
|
/** Metadata keys that all map to the one-line scene description. */
|
||||||
const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]);
|
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 {
|
interface FrontmatterResult {
|
||||||
globals: StoryboardGlobals;
|
globals: StoryboardGlobals;
|
||||||
|
|||||||
@@ -498,7 +498,10 @@ export function StudioApp() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{viewModeValue.viewMode === "storyboard" && (
|
{viewModeValue.viewMode === "storyboard" && (
|
||||||
<StoryboardView projectId={projectId} />
|
<StoryboardView
|
||||||
|
projectId={projectId}
|
||||||
|
onSelectComposition={handleSelectComposition}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Timeline stage stays mounted (just hidden) in storyboard mode,
|
{/* Timeline stage stays mounted (just hidden) in storyboard mode,
|
||||||
so preview/player/gesture/render state survives the toggle. */}
|
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 type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||||
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
import { FramePoster, posterTime } from "./FramePoster";
|
||||||
import { FRAME_STATUS_META } from "./frameStatus";
|
import { FRAME_STATUS_META } from "./frameStatus";
|
||||||
|
|
||||||
export interface StoryboardFrameTileProps {
|
export interface StoryboardFrameTileProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
frame: StoryboardFrameView;
|
frame: StoryboardFrameView;
|
||||||
|
/** Open this frame in the full-area focus view. */
|
||||||
|
onOpen: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TILE_WIDTH = 360;
|
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 {
|
function firstLine(text: string): string {
|
||||||
return (
|
return (
|
||||||
text
|
text
|
||||||
@@ -32,9 +26,9 @@ function placeholderMessage(frame: StoryboardFrameView): string {
|
|||||||
return "No preview";
|
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
|
// 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 meta = FRAME_STATUS_META[frame.status];
|
||||||
const renderable = frame.srcExists && frame.status !== "outline";
|
const renderable = frame.srcExists && frame.status !== "outline";
|
||||||
const title = frame.title ?? `Frame ${frame.index}`;
|
const title = frame.title ?? `Frame ${frame.index}`;
|
||||||
@@ -42,7 +36,11 @@ export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTilePro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article style={{ width: TILE_WIDTH }}>
|
<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">
|
<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}
|
{frame.number ?? frame.index}
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +54,7 @@ export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTilePro
|
|||||||
) : (
|
) : (
|
||||||
<FrameTilePlaceholder frame={frame} />
|
<FrameTilePlaceholder frame={frame} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div className="mt-2 flex items-start justify-between gap-2">
|
<div className="mt-2 flex items-start justify-between gap-2">
|
||||||
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
|
<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 }) {
|
function FrameTilePlaceholder({ frame }: { frame: StoryboardFrameView }) {
|
||||||
return (
|
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">
|
<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 {
|
export interface StoryboardGridProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
frames: StoryboardFrameView[];
|
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. */
|
/** 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) {
|
if (frames.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
|
<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 (
|
return (
|
||||||
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-8">
|
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-8">
|
||||||
{frames.map((frame) => (
|
{frames.map((frame) => (
|
||||||
<StoryboardFrameTile key={frame.index} projectId={projectId} frame={frame} />
|
<StoryboardFrameTile
|
||||||
|
key={frame.index}
|
||||||
|
projectId={projectId}
|
||||||
|
frame={frame}
|
||||||
|
onOpen={onOpenFrame}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { StoryboardGrid } from "./StoryboardGrid";
|
|||||||
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
|
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
|
||||||
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
||||||
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
|
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
|
||||||
|
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
|
||||||
|
|
||||||
type SubView = "board" | "source";
|
type SubView = "board" | "source";
|
||||||
|
|
||||||
@@ -13,12 +14,25 @@ export interface StoryboardLoadedProps {
|
|||||||
data: StoryboardResponse;
|
data: StoryboardResponse;
|
||||||
/** Re-fetch the manifest after a source edit is saved. */
|
/** Re-fetch the manifest after a source edit is saved. */
|
||||||
reload: () => void;
|
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). */
|
function clampIndex(index: number, count: number): number {
|
||||||
export function StoryboardLoaded({ projectId, data, reload }: StoryboardLoadedProps) {
|
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 [subView, setSubView] = useState<SubView>("board");
|
||||||
const [sourceDirty, setSourceDirty] = useState(false);
|
const [sourceDirty, setSourceDirty] = useState(false);
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
const sourceFiles = useMemo<SourceFile[]>(() => {
|
const sourceFiles = useMemo<SourceFile[]>(() => {
|
||||||
const files: SourceFile[] = [{ path: data.path, label: data.path }];
|
const files: SourceFile[] = [{ path: data.path, label: data.path }];
|
||||||
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.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);
|
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 (
|
return (
|
||||||
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
<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">
|
<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">
|
<div className="mt-5">
|
||||||
<StoryboardStatusLegend />
|
<StoryboardStatusLegend />
|
||||||
</div>
|
</div>
|
||||||
<StoryboardGrid projectId={projectId} frames={data.frames} />
|
<StoryboardGrid
|
||||||
|
projectId={projectId}
|
||||||
|
frames={data.frames}
|
||||||
|
onOpenFrame={setFocusedIndex}
|
||||||
|
/>
|
||||||
{data.script && <StoryboardScriptPanel script={data.script} />}
|
{data.script && <StoryboardScriptPanel script={data.script} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { StoryboardLoaded } from "./StoryboardLoaded";
|
|||||||
|
|
||||||
export interface StoryboardViewProps {
|
export interface StoryboardViewProps {
|
||||||
projectId: string;
|
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.
|
* {@link StoryboardLoaded} owns the Board ↔ Source experience.
|
||||||
*/
|
*/
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
export function StoryboardView({ projectId }: StoryboardViewProps) {
|
export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
|
||||||
const { data, loading, error, reload } = useStoryboard(projectId);
|
const { data, loading, error, reload } = useStoryboard(projectId);
|
||||||
|
|
||||||
if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
|
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 }) {
|
function StoryboardFrame({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user