mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +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 = [
|
||||
{ label: "Arc", value: globals.arc },
|
||||
{ label: "Audience", value: globals.audience },
|
||||
{ label: "Voice", value: globals.extra.voice },
|
||||
{ label: "Format", value: globals.format },
|
||||
{ label: "Frames", value: String(frameCount) },
|
||||
].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 { useStoryboard } from "../../hooks/useStoryboard";
|
||||
import { StoryboardDirection } from "./StoryboardDirection";
|
||||
import { StoryboardGrid } from "./StoryboardGrid";
|
||||
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
|
||||
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
||||
|
||||
export interface StoryboardViewProps {
|
||||
projectId: string;
|
||||
@@ -8,8 +11,8 @@ export interface StoryboardViewProps {
|
||||
|
||||
/**
|
||||
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
|
||||
* is `storyboard`. PR2 lands the shell (global direction + states); the frame
|
||||
* contact-sheet grid arrives in PR3.
|
||||
* is `storyboard`: renders the global direction plus the frame contact sheet,
|
||||
* with loading / error / empty states.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StoryboardView({ projectId }: StoryboardViewProps) {
|
||||
@@ -35,11 +38,11 @@ export function StoryboardView({ projectId }: StoryboardViewProps) {
|
||||
return (
|
||||
<StoryboardFrame>
|
||||
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
|
||||
{/* PR3: frame contact-sheet grid renders here. */}
|
||||
<div className="mt-8 rounded-lg border border-dashed border-neutral-800 px-6 py-12 text-center text-sm text-neutral-500">
|
||||
{data.frames.length} frame{data.frames.length === 1 ? "" : "s"} parsed — the contact sheet
|
||||
renders here next.
|
||||
<div className="mt-5">
|
||||
<StoryboardStatusLegend />
|
||||
</div>
|
||||
<StoryboardGrid projectId={projectId} frames={data.frames} />
|
||||
{data.script && <StoryboardScriptPanel script={data.script} />}
|
||||
</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"];
|
||||
Reference in New Issue
Block a user