mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(studio): storyboard frame contact-sheet grid (#1530)
Third PR in the Studio storyboarding stack. Renders the frames as a live contact sheet inside the storyboard view. - StoryboardGrid: ordered, responsive grid of frame tiles. - StoryboardFrameTile: number badge, scaled non-interactive live preview iframe (via /api/projects/:id/preview/comp/<src>), title, duration, transition, and a status chip (outline / built / animated). - Frames that are outline-only or whose src is missing render an explicit placeholder instead of an iframe. - StoryboardView swaps its placeholder for the real grid. With PR1-PR3 the storyboard view is end-to-end viewable against the storyboard-sample fixture for UI/UX feedback. 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
195d7aa7bd
commit
015529e663
@@ -13,6 +13,7 @@ export function StoryboardDirection({ globals, frameCount }: StoryboardDirection
|
|||||||
const meta = [
|
const meta = [
|
||||||
{ label: "Arc", value: globals.arc },
|
{ label: "Arc", value: globals.arc },
|
||||||
{ label: "Audience", value: globals.audience },
|
{ label: "Audience", value: globals.audience },
|
||||||
|
{ label: "Voice", value: globals.extra.voice },
|
||||||
{ label: "Format", value: globals.format },
|
{ label: "Format", value: globals.format },
|
||||||
{ label: "Frames", value: String(frameCount) },
|
{ label: "Frames", value: String(frameCount) },
|
||||||
].filter((item): item is { label: string; value: string } => Boolean(item.value));
|
].filter((item): item is { label: string; value: string } => Boolean(item.value));
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||||
|
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
||||||
|
import { FRAME_STATUS_META } from "./frameStatus";
|
||||||
|
|
||||||
|
export interface StoryboardFrameTileProps {
|
||||||
|
projectId: string;
|
||||||
|
frame: StoryboardFrameView;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
.split("\n")
|
||||||
|
.find((line) => line.trim().length > 0)
|
||||||
|
?.trim() ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholderMessage(frame: StoryboardFrameView): string {
|
||||||
|
if (frame.status === "outline") return "Not built yet";
|
||||||
|
if (frame.src && !frame.srcExists) return "Frame file not found";
|
||||||
|
return "No preview";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single contact-sheet tile: poster preview + its metadata. */
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
export function StoryboardFrameTile({ projectId, frame }: StoryboardFrameTileProps) {
|
||||||
|
const meta = FRAME_STATUS_META[frame.status];
|
||||||
|
const renderable = frame.srcExists && frame.status !== "outline";
|
||||||
|
const title = frame.title ?? `Frame ${frame.index}`;
|
||||||
|
const sceneLine = frame.scene ?? firstLine(frame.narrative);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article style={{ width: TILE_WIDTH }}>
|
||||||
|
<div className="relative aspect-video overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
|
||||||
|
<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>
|
||||||
|
{renderable && frame.src ? (
|
||||||
|
<FramePoster
|
||||||
|
projectId={projectId}
|
||||||
|
src={frame.src}
|
||||||
|
seconds={posterTime(frame)}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FrameTilePlaceholder frame={frame} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
|
||||||
|
<span
|
||||||
|
title={meta.tooltip}
|
||||||
|
className={`shrink-0 cursor-default rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${meta.chipClass}`}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sceneLine && <p className="mt-0.5 line-clamp-2 text-xs text-neutral-400">{sceneLine}</p>}
|
||||||
|
{frame.voiceover && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs italic text-neutral-500">
|
||||||
|
<span aria-hidden="true">🎙 </span>“{frame.voiceover}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex gap-3 text-[11px] text-neutral-600">
|
||||||
|
{frame.duration && <span>{frame.duration}</span>}
|
||||||
|
{frame.transitionIn && <span>↘ {frame.transitionIn}</span>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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">
|
||||||
|
<span className="text-xs font-medium text-neutral-400">{frame.title ?? "Outline"}</span>
|
||||||
|
<span className="text-[11px] text-neutral-600">{placeholderMessage(frame)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||||
|
import { StoryboardFrameTile } from "./StoryboardFrameTile";
|
||||||
|
|
||||||
|
export interface StoryboardGridProps {
|
||||||
|
projectId: string;
|
||||||
|
frames: StoryboardFrameView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The contact sheet: ordered frame tiles in a responsive grid. */
|
||||||
|
export function StoryboardGrid({ projectId, frames }: 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">
|
||||||
|
This storyboard has no frames yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { StoryboardScript } from "../../hooks/useStoryboard";
|
||||||
|
|
||||||
|
export interface StoryboardScriptPanelProps {
|
||||||
|
script: StoryboardScript;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible view of the companion narration script (SCRIPT.md). The mature
|
||||||
|
* pipeline keeps the full voiceover script — voice settings, per-line delivery
|
||||||
|
* and timing — in this file; here it's surfaced read-only alongside the frames.
|
||||||
|
* (Per-frame VO iteration will live in the frame focus view.)
|
||||||
|
*/
|
||||||
|
export function StoryboardScriptPanel({ script }: StoryboardScriptPanelProps) {
|
||||||
|
if (!script.exists) return null;
|
||||||
|
return (
|
||||||
|
<details className="mt-10 rounded-lg border border-neutral-800 bg-neutral-900/50">
|
||||||
|
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-neutral-300">
|
||||||
|
Narration script
|
||||||
|
<span className="ml-2 font-normal text-neutral-500">{script.path}</span>
|
||||||
|
</summary>
|
||||||
|
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap border-t border-neutral-800 px-4 py-3 text-xs leading-relaxed text-neutral-400">
|
||||||
|
{script.content}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explains the frame lifecycle: a frame advances outline → built → animated.
|
||||||
|
* Mirrors the status chips on each tile (shares FRAME_STATUS_META).
|
||||||
|
*/
|
||||||
|
export function StoryboardStatusLegend() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
|
||||||
|
<span className="uppercase tracking-wider text-neutral-600">Status</span>
|
||||||
|
{FRAME_STATUS_ORDER.map((status, i) => (
|
||||||
|
<span key={status} className="flex items-center gap-1.5">
|
||||||
|
{i > 0 && <span className="text-neutral-700">→</span>}
|
||||||
|
<span className={`h-2 w-2 rounded-full ${FRAME_STATUS_META[status].dotClass}`} />
|
||||||
|
<span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
|
||||||
|
<span className="text-neutral-500">— {FRAME_STATUS_META[status].description}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useStoryboard } from "../../hooks/useStoryboard";
|
import { useStoryboard } from "../../hooks/useStoryboard";
|
||||||
import { StoryboardDirection } from "./StoryboardDirection";
|
import { StoryboardDirection } from "./StoryboardDirection";
|
||||||
|
import { StoryboardGrid } from "./StoryboardGrid";
|
||||||
|
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
|
||||||
|
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
||||||
|
|
||||||
export interface StoryboardViewProps {
|
export interface StoryboardViewProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -8,8 +11,8 @@ export interface StoryboardViewProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
|
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
|
||||||
* is `storyboard`. PR2 lands the shell (global direction + states); the frame
|
* is `storyboard`: renders the global direction plus the frame contact sheet,
|
||||||
* contact-sheet grid arrives in PR3.
|
* with loading / error / empty states.
|
||||||
*/
|
*/
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
export function StoryboardView({ projectId }: StoryboardViewProps) {
|
export function StoryboardView({ projectId }: StoryboardViewProps) {
|
||||||
@@ -35,11 +38,11 @@ export function StoryboardView({ projectId }: StoryboardViewProps) {
|
|||||||
return (
|
return (
|
||||||
<StoryboardFrame>
|
<StoryboardFrame>
|
||||||
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
|
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
|
||||||
{/* PR3: frame contact-sheet grid renders here. */}
|
<div className="mt-5">
|
||||||
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
|
<StoryboardStatusLegend />
|
||||||
{data.frames.length} frame{data.frames.length === 1 ? "" : "s"} parsed — the contact sheet
|
|
||||||
renders here next.
|
|
||||||
</div>
|
</div>
|
||||||
|
<StoryboardGrid projectId={projectId} frames={data.frames} />
|
||||||
|
{data.script && <StoryboardScriptPanel script={data.script} />}
|
||||||
</StoryboardFrame>
|
</StoryboardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FrameStatus } from "@hyperframes/core/storyboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for how each frame lifecycle status is presented —
|
||||||
|
* label, tooltip, description, and the chip/dot color classes — so the tile
|
||||||
|
* chip and the legend dot can't drift apart.
|
||||||
|
*/
|
||||||
|
export const FRAME_STATUS_META: Record<
|
||||||
|
FrameStatus,
|
||||||
|
{ label: string; tooltip: string; description: string; chipClass: string; dotClass: string }
|
||||||
|
> = {
|
||||||
|
outline: {
|
||||||
|
label: "Outline",
|
||||||
|
tooltip: "Planned in text — no HTML frame built yet.",
|
||||||
|
description: "Planned in text. Story and intent exist; the visual isn’t built.",
|
||||||
|
chipClass: "bg-neutral-800 text-neutral-300",
|
||||||
|
dotClass: "bg-neutral-500",
|
||||||
|
},
|
||||||
|
built: {
|
||||||
|
label: "Built",
|
||||||
|
tooltip: "Static HTML frame built — not animated yet.",
|
||||||
|
description: "The HTML frame exists as a static key moment. Look is locked; motion isn’t.",
|
||||||
|
chipClass: "bg-sky-500/20 text-sky-300",
|
||||||
|
dotClass: "bg-sky-400",
|
||||||
|
},
|
||||||
|
animated: {
|
||||||
|
label: "Animated",
|
||||||
|
tooltip: "Keyframed and animated — plays in the final cut.",
|
||||||
|
description: "Keyframed into motion. This is the file stitched into the final video.",
|
||||||
|
chipClass: "bg-emerald-500/20 text-emerald-300",
|
||||||
|
dotClass: "bg-emerald-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The lifecycle order an agent advances each frame through. */
|
||||||
|
export const FRAME_STATUS_ORDER: FrameStatus[] = ["outline", "built", "animated"];
|
||||||
@@ -12,6 +12,13 @@ export interface StoryboardFrameView extends StoryboardFrame {
|
|||||||
srcExists: boolean;
|
srcExists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The companion narration script (SCRIPT.md), when present alongside the storyboard. */
|
||||||
|
export interface StoryboardScript {
|
||||||
|
exists: boolean;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Shape of `GET /api/projects/:id/storyboard`. */
|
/** Shape of `GET /api/projects/:id/storyboard`. */
|
||||||
export interface StoryboardResponse {
|
export interface StoryboardResponse {
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
@@ -19,6 +26,7 @@ export interface StoryboardResponse {
|
|||||||
globals: StoryboardGlobals;
|
globals: StoryboardGlobals;
|
||||||
frames: StoryboardFrameView[];
|
frames: StoryboardFrameView[];
|
||||||
warnings: StoryboardWarning[];
|
warnings: StoryboardWarning[];
|
||||||
|
script?: StoryboardScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseStoryboardResult {
|
export interface UseStoryboardResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user