mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
feat(studio): storyboard view-mode toggle and shell (#1529)
Second PR in the Studio storyboarding stack. Adds the top-level toggle between the storyboard and the timeline/preview stage, behind the flag. - STUDIO_STORYBOARD_ENABLED flag (VITE_STUDIO_ENABLE_STORYBOARD, default off) now gates the UI. - ViewModeContext: timeline|storyboard state mirrored to the ?view= query param, so it survives reloads and an agent can deep-link ?view=storyboard. - Segmented Storyboard|Preview control in StudioHeader (flag-gated). - StudioApp swaps the whole center stage for a full-width StoryboardView when storyboard mode is active. - useStoryboard hook + StoryboardView shell: global-direction header, loading/error/empty states. The frame contact-sheet grid lands in PR3. - Extract StudioOverlays from App.tsx to stay within the 600-line studio decomposition budget. 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
8a13a07074
commit
195d7aa7bd
@@ -3,11 +3,13 @@ import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
STUDIO_MANUAL_EDITING_DISABLED_TITLE,
|
||||
STUDIO_STORYBOARD_ENABLED,
|
||||
} from "./editor/manualEditingAvailability";
|
||||
import { getHistoryShortcutLabel } from "../utils/studioHelpers";
|
||||
import { useStudioShellContext } from "../contexts/StudioContext";
|
||||
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useDomEditActionsContext } from "../contexts/DomEditContext";
|
||||
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
export interface StudioHeaderProps {
|
||||
@@ -141,6 +143,46 @@ function HyperframesLogo() {
|
||||
);
|
||||
}
|
||||
|
||||
const VIEW_MODE_OPTIONS: Array<{ mode: StudioViewMode; label: string }> = [
|
||||
{ mode: "storyboard", label: "Storyboard" },
|
||||
{ mode: "timeline", label: "Preview" },
|
||||
];
|
||||
|
||||
/** Segmented control switching the main stage between storyboard and preview. */
|
||||
function ViewModeToggle() {
|
||||
const { viewMode, setViewMode } = useViewMode();
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-md bg-neutral-800 p-0.5"
|
||||
role="tablist"
|
||||
aria-label="Studio view"
|
||||
>
|
||||
{VIEW_MODE_OPTIONS.map(({ mode, label }) => {
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
if (active) return;
|
||||
trackStudioEvent("view_mode_toggle", { mode });
|
||||
setViewMode(mode);
|
||||
}}
|
||||
className={`rounded px-3 py-1 text-[11px] font-medium transition-colors ${
|
||||
active ? "bg-neutral-200 text-neutral-900" : "text-neutral-400 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioHeader({
|
||||
captureFrameHref,
|
||||
captureFrameFilename,
|
||||
@@ -164,6 +206,8 @@ export function StudioHeader({
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
|
||||
</div>
|
||||
{/* Center: storyboard / preview toggle (flag-gated) */}
|
||||
{STUDIO_STORYBOARD_ENABLED && <ViewModeToggle />}
|
||||
{/* Right: toolbar buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { LintModal } from "./LintModal";
|
||||
import { AskAgentModal } from "./AskAgentModal";
|
||||
import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
|
||||
import { StudioToast } from "./StudioToast";
|
||||
import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
|
||||
import type { useDomEditSession } from "../hooks/useDomEditSession";
|
||||
import type { useToast } from "../hooks/useToast";
|
||||
|
||||
type LintFindings = ComponentProps<typeof LintModal>["findings"];
|
||||
|
||||
export interface StudioOverlaysProps {
|
||||
projectId: string;
|
||||
lintModal: LintFindings | null;
|
||||
closeLintModal: () => void;
|
||||
consoleErrors: LintFindings | null;
|
||||
clearConsoleErrors: () => void;
|
||||
domEditSession: ReturnType<typeof useDomEditSession>;
|
||||
activeCompPath: string | null;
|
||||
dragOverlayActive: boolean;
|
||||
appToast: ReturnType<typeof useToast>["appToast"];
|
||||
dismissToast: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating overlays for the studio shell: lint / console-error modals, the
|
||||
* ask-agent modal, the global drag overlay, and the toast. Extracted from
|
||||
* `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioOverlays({
|
||||
projectId,
|
||||
lintModal,
|
||||
closeLintModal,
|
||||
consoleErrors,
|
||||
clearConsoleErrors,
|
||||
domEditSession,
|
||||
activeCompPath,
|
||||
dragOverlayActive,
|
||||
appToast,
|
||||
dismissToast,
|
||||
}: StudioOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
{lintModal !== null && (
|
||||
<LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
|
||||
)}
|
||||
{consoleErrors !== null && consoleErrors.length > 0 && (
|
||||
<LintModal findings={consoleErrors} projectId={projectId} onClose={clearConsoleErrors} />
|
||||
)}
|
||||
{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
|
||||
<AskAgentModal
|
||||
selectionLabel={domEditSession.domEditSelection.label}
|
||||
contextPreview={buildAgentContextPreview(domEditSession.domEditSelection, activeCompPath)}
|
||||
anchorPoint={domEditSession.agentModalAnchorPoint}
|
||||
onSubmit={domEditSession.handleAgentModalSubmit}
|
||||
onClose={() => {
|
||||
domEditSession.setAgentModalOpen(false);
|
||||
domEditSession.setAgentPromptSelectionContext(undefined);
|
||||
domEditSession.setAgentModalAnchorPoint(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dragOverlayActive && <StudioGlobalDragOverlay />}
|
||||
{appToast && (
|
||||
<StudioToast message={appToast.message} tone={appToast.tone} onDismiss={dismissToast} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
true,
|
||||
);
|
||||
|
||||
// Storyboard view: a top-level, toggleable view that renders STORYBOARD.md as a
|
||||
// contact sheet of live HTML frame tiles, replacing the timeline/preview stage.
|
||||
// Opt-in / off by default until the experience is ready for broad exposure.
|
||||
// VITE_STUDIO_ENABLE_STORYBOARD=1 npx hyperframes preview
|
||||
export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
|
||||
env,
|
||||
["VITE_STUDIO_ENABLE_STORYBOARD", "VITE_STUDIO_STORYBOARD_ENABLED"],
|
||||
false,
|
||||
);
|
||||
|
||||
// When disabled (the default), drag/resize/rotate commits always take the CSS
|
||||
// persist path instead of being intercepted into GSAP script keyframe
|
||||
// mutations. The keyframe intercept rewrites timeline tweens from drag
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { StoryboardGlobals } from "@hyperframes/core/storyboard";
|
||||
|
||||
export interface StoryboardDirectionProps {
|
||||
globals: StoryboardGlobals;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global direction header: the message/thesis plus arc, audience, and format.
|
||||
* This is the storyboard's "north star" pulled from the manifest frontmatter.
|
||||
*/
|
||||
export function StoryboardDirection({ globals, frameCount }: StoryboardDirectionProps) {
|
||||
const meta = [
|
||||
{ label: "Arc", value: globals.arc },
|
||||
{ label: "Audience", value: globals.audience },
|
||||
{ label: "Format", value: globals.format },
|
||||
{ label: "Frames", value: String(frameCount) },
|
||||
].filter((item): item is { label: string; value: string } => Boolean(item.value));
|
||||
|
||||
return (
|
||||
<header className="border-b border-neutral-800 pb-5">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-neutral-500">
|
||||
Storyboard
|
||||
</div>
|
||||
{globals.message ? (
|
||||
<h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-100">
|
||||
{globals.message}
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-400">
|
||||
Untitled storyboard
|
||||
</h1>
|
||||
)}
|
||||
<dl className="mt-4 flex flex-wrap gap-x-8 gap-y-2">
|
||||
{meta.map((item) => (
|
||||
<div key={item.label} className="flex items-baseline gap-2">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-neutral-500">{item.label}</dt>
|
||||
<dd className="text-sm text-neutral-300">{item.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useStoryboard } from "../../hooks/useStoryboard";
|
||||
import { StoryboardDirection } from "./StoryboardDirection";
|
||||
|
||||
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.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StoryboardView({ projectId }: StoryboardViewProps) {
|
||||
const { data, loading, error } = useStoryboard(projectId);
|
||||
|
||||
if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
|
||||
if (error) {
|
||||
return (
|
||||
<StoryboardFrame>
|
||||
<Message tone="error">Couldn’t load the storyboard: {error}</Message>
|
||||
</StoryboardFrame>
|
||||
);
|
||||
}
|
||||
if (!data) return <StoryboardFrame>{null}</StoryboardFrame>;
|
||||
if (!data.exists) {
|
||||
return (
|
||||
<StoryboardFrame>
|
||||
<EmptyState path={data.path} />
|
||||
</StoryboardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</StoryboardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function StoryboardFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto bg-neutral-950 text-neutral-200">
|
||||
<div className="mx-auto max-w-[1400px] px-8 py-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ children, tone = "muted" }: { children: ReactNode; tone?: "muted" | "error" }) {
|
||||
return (
|
||||
<div
|
||||
className={`px-6 py-12 text-center text-sm ${
|
||||
tone === "error" ? "text-red-400" : "text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ path }: { path: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-neutral-800 px-6 py-16 text-center">
|
||||
<h2 className="text-base font-semibold text-neutral-300">No storyboard yet</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-neutral-500">
|
||||
Add a <code className="rounded bg-neutral-900 px-1 py-0.5 text-neutral-400">{path}</code> at
|
||||
the project root to plan this video frame by frame. Your agent can create and iterate on it
|
||||
for you.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user