Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
133 changes: 133 additions & 0 deletions packages/studio/src/components/storyboard/StoryboardFrameTile.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
26 changes: 26 additions & 0 deletions packages/studio/src/components/storyboard/StoryboardGrid.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
15 changes: 9 additions & 6 deletions packages/studio/src/components/storyboard/StoryboardView.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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;
}

/**
* 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) {
Expand All @@ -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>
);
}
Expand Down
36 changes: 36 additions & 0 deletions packages/studio/src/components/storyboard/frameStatus.ts
Original file line number Diff line number Diff line change
@@ -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"];
8 changes: 8 additions & 0 deletions packages/studio/src/hooks/useStoryboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,21 @@ export interface StoryboardFrameView extends StoryboardFrame {
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`. */
export interface StoryboardResponse {
exists: boolean;
path: string;
globals: StoryboardGlobals;
frames: StoryboardFrameView[];
warnings: StoryboardWarning[];
script?: StoryboardScript;
}

export interface UseStoryboardResult {
Expand Down
Loading