@hyperframes/studio 0.6.110 → 0.6.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/dist/assets/index-1C8oFiPi.css +0 -1
- package/dist/assets/index-D-3sGz65.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- package/src/utils/sdkShadowNumeric.ts +0 -11
|
@@ -3,11 +3,13 @@ import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
|
|
|
3
3
|
import {
|
|
4
4
|
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
5
5
|
STUDIO_MANUAL_EDITING_DISABLED_TITLE,
|
|
6
|
+
STUDIO_STORYBOARD_ENABLED,
|
|
6
7
|
} from "./editor/manualEditingAvailability";
|
|
7
8
|
import { getHistoryShortcutLabel } from "../utils/studioHelpers";
|
|
8
9
|
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
9
10
|
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
10
11
|
import { useDomEditActionsContext } from "../contexts/DomEditContext";
|
|
12
|
+
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
|
|
11
13
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
12
14
|
|
|
13
15
|
export interface StudioHeaderProps {
|
|
@@ -141,6 +143,46 @@ function HyperframesLogo() {
|
|
|
141
143
|
);
|
|
142
144
|
}
|
|
143
145
|
|
|
146
|
+
const VIEW_MODE_OPTIONS: Array<{ mode: StudioViewMode; label: string }> = [
|
|
147
|
+
{ mode: "storyboard", label: "Storyboard" },
|
|
148
|
+
{ mode: "timeline", label: "Preview" },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
/** Segmented control switching the main stage between storyboard and preview. */
|
|
152
|
+
function ViewModeToggle() {
|
|
153
|
+
const { viewMode, setViewMode } = useViewMode();
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
className="flex items-center gap-0.5 rounded-md bg-neutral-800 p-0.5"
|
|
157
|
+
role="tablist"
|
|
158
|
+
aria-label="Studio view"
|
|
159
|
+
>
|
|
160
|
+
{VIEW_MODE_OPTIONS.map(({ mode, label }) => {
|
|
161
|
+
const active = viewMode === mode;
|
|
162
|
+
return (
|
|
163
|
+
<button
|
|
164
|
+
key={mode}
|
|
165
|
+
type="button"
|
|
166
|
+
role="tab"
|
|
167
|
+
aria-selected={active}
|
|
168
|
+
onClick={() => {
|
|
169
|
+
if (active) return;
|
|
170
|
+
trackStudioEvent("view_mode_toggle", { mode });
|
|
171
|
+
setViewMode(mode);
|
|
172
|
+
}}
|
|
173
|
+
className={`rounded px-3 py-1 text-[11px] font-medium transition-colors ${
|
|
174
|
+
active ? "bg-neutral-200 text-neutral-900" : "text-neutral-400 hover:text-neutral-200"
|
|
175
|
+
}`}
|
|
176
|
+
>
|
|
177
|
+
{label}
|
|
178
|
+
</button>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// fallow-ignore-next-line complexity
|
|
144
186
|
export function StudioHeader({
|
|
145
187
|
captureFrameHref,
|
|
146
188
|
captureFrameFilename,
|
|
@@ -164,6 +206,8 @@ export function StudioHeader({
|
|
|
164
206
|
</span>
|
|
165
207
|
<span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
|
|
166
208
|
</div>
|
|
209
|
+
{/* Center: storyboard / preview toggle (flag-gated) */}
|
|
210
|
+
{STUDIO_STORYBOARD_ENABLED && <ViewModeToggle />}
|
|
167
211
|
{/* Right: toolbar buttons */}
|
|
168
212
|
<div className="flex items-center gap-1.5">
|
|
169
213
|
<button
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { LintModal } from "./LintModal";
|
|
3
|
+
import { AskAgentModal } from "./AskAgentModal";
|
|
4
|
+
import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
|
|
5
|
+
import { StudioToast } from "./StudioToast";
|
|
6
|
+
import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
|
|
7
|
+
import type { useDomEditSession } from "../hooks/useDomEditSession";
|
|
8
|
+
import type { useToast } from "../hooks/useToast";
|
|
9
|
+
|
|
10
|
+
type LintFindings = ComponentProps<typeof LintModal>["findings"];
|
|
11
|
+
|
|
12
|
+
export interface StudioOverlaysProps {
|
|
13
|
+
projectId: string;
|
|
14
|
+
lintModal: LintFindings | null;
|
|
15
|
+
closeLintModal: () => void;
|
|
16
|
+
consoleErrors: LintFindings | null;
|
|
17
|
+
clearConsoleErrors: () => void;
|
|
18
|
+
domEditSession: ReturnType<typeof useDomEditSession>;
|
|
19
|
+
activeCompPath: string | null;
|
|
20
|
+
dragOverlayActive: boolean;
|
|
21
|
+
appToast: ReturnType<typeof useToast>["appToast"];
|
|
22
|
+
dismissToast: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Floating overlays for the studio shell: lint / console-error modals, the
|
|
27
|
+
* ask-agent modal, the global drag overlay, and the toast. Extracted from
|
|
28
|
+
* `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
|
|
29
|
+
*/
|
|
30
|
+
// fallow-ignore-next-line complexity
|
|
31
|
+
export function StudioOverlays({
|
|
32
|
+
projectId,
|
|
33
|
+
lintModal,
|
|
34
|
+
closeLintModal,
|
|
35
|
+
consoleErrors,
|
|
36
|
+
clearConsoleErrors,
|
|
37
|
+
domEditSession,
|
|
38
|
+
activeCompPath,
|
|
39
|
+
dragOverlayActive,
|
|
40
|
+
appToast,
|
|
41
|
+
dismissToast,
|
|
42
|
+
}: StudioOverlaysProps) {
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
{lintModal !== null && (
|
|
46
|
+
<LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
|
|
47
|
+
)}
|
|
48
|
+
{consoleErrors !== null && consoleErrors.length > 0 && (
|
|
49
|
+
<LintModal findings={consoleErrors} projectId={projectId} onClose={clearConsoleErrors} />
|
|
50
|
+
)}
|
|
51
|
+
{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
|
|
52
|
+
<AskAgentModal
|
|
53
|
+
selectionLabel={domEditSession.domEditSelection.label}
|
|
54
|
+
contextPreview={buildAgentContextPreview(domEditSession.domEditSelection, activeCompPath)}
|
|
55
|
+
anchorPoint={domEditSession.agentModalAnchorPoint}
|
|
56
|
+
onSubmit={domEditSession.handleAgentModalSubmit}
|
|
57
|
+
onClose={() => {
|
|
58
|
+
domEditSession.setAgentModalOpen(false);
|
|
59
|
+
domEditSession.setAgentPromptSelectionContext(undefined);
|
|
60
|
+
domEditSession.setAgentModalAnchorPoint(null);
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
{dragOverlayActive && <StudioGlobalDragOverlay />}
|
|
65
|
+
{appToast && (
|
|
66
|
+
<StudioToast message={appToast.message} tone={appToast.tone} onDismiss={dismissToast} />
|
|
67
|
+
)}
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -12,26 +12,26 @@ import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language
|
|
|
12
12
|
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
|
13
13
|
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
|
|
14
14
|
import { oneDark } from "@codemirror/theme-one-dark";
|
|
15
|
+
import type { Extension } from "@codemirror/state";
|
|
15
16
|
import { html } from "@codemirror/lang-html";
|
|
16
17
|
import { css } from "@codemirror/lang-css";
|
|
17
18
|
import { javascript } from "@codemirror/lang-javascript";
|
|
19
|
+
import { markdown } from "@codemirror/lang-markdown";
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return html();
|
|
34
|
-
}
|
|
21
|
+
const LANGUAGE_EXTENSIONS: Record<string, () => Extension> = {
|
|
22
|
+
html: () => html(),
|
|
23
|
+
css: () => css(),
|
|
24
|
+
markdown: () => markdown(),
|
|
25
|
+
md: () => markdown(),
|
|
26
|
+
javascript: () => javascript(),
|
|
27
|
+
js: () => javascript(),
|
|
28
|
+
typescript: () => javascript({ typescript: true }),
|
|
29
|
+
ts: () => javascript({ typescript: true }),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function getLanguageExtension(language: string): Extension {
|
|
33
|
+
const factory = LANGUAGE_EXTENSIONS[language] ?? html;
|
|
34
|
+
return factory();
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function detectLanguage(filePath: string): string {
|
|
@@ -45,6 +45,8 @@ function detectLanguage(filePath: string): string {
|
|
|
45
45
|
jsx: "javascript",
|
|
46
46
|
tsx: "typescript",
|
|
47
47
|
json: "javascript",
|
|
48
|
+
md: "markdown",
|
|
49
|
+
markdown: "markdown",
|
|
48
50
|
};
|
|
49
51
|
return map[ext] ?? "html";
|
|
50
52
|
}
|
|
@@ -74,6 +76,7 @@ export const SourceEditor = memo(function SourceEditor({
|
|
|
74
76
|
const contentRef = useRef(content);
|
|
75
77
|
contentRef.current = content;
|
|
76
78
|
|
|
79
|
+
// fallow-ignore-next-line complexity
|
|
77
80
|
const mountEditor = useCallback(
|
|
78
81
|
(node: HTMLDivElement | null) => {
|
|
79
82
|
if (editorRef.current) {
|
|
@@ -82,6 +82,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
82
82
|
true,
|
|
83
83
|
);
|
|
84
84
|
|
|
85
|
+
// Storyboard view: a top-level, toggleable view that renders STORYBOARD.md as a
|
|
86
|
+
// contact sheet of live HTML frame tiles, replacing the timeline/preview stage.
|
|
87
|
+
// Opt-in / off by default until the experience is ready for broad exposure.
|
|
88
|
+
// VITE_STUDIO_ENABLE_STORYBOARD=1 npx hyperframes preview
|
|
89
|
+
export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
|
|
90
|
+
env,
|
|
91
|
+
["VITE_STUDIO_ENABLE_STORYBOARD", "VITE_STUDIO_STORYBOARD_ENABLED"],
|
|
92
|
+
false,
|
|
93
|
+
);
|
|
94
|
+
|
|
85
95
|
// When disabled (the default), drag/resize/rotate commits always take the CSS
|
|
86
96
|
// persist path instead of being intercepted into GSAP script keyframe
|
|
87
97
|
// mutations. The keyframe intercept rewrites timeline tweens from drag
|
|
@@ -94,14 +104,22 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
94
104
|
|
|
95
105
|
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
|
96
106
|
|
|
97
|
-
// Stage 7 Step
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
|
|
108
|
+
// instead of the server patch-element API. Default false; enable via
|
|
109
|
+
// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
|
|
110
|
+
export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
|
|
111
|
+
env,
|
|
112
|
+
["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Resolver-parity tripwire (telemetry-only, decoupled from cutover).
|
|
117
|
+
// Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on
|
|
118
|
+
// divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false.
|
|
119
|
+
// Soak gate: retire once zero element_not_found divergences over a clean window.
|
|
120
|
+
export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
|
|
103
121
|
env,
|
|
104
|
-
["
|
|
122
|
+
["VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED"],
|
|
105
123
|
true,
|
|
106
124
|
);
|
|
107
125
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
|
3
|
+
|
|
4
|
+
export interface FramePosterProps {
|
|
5
|
+
projectId: string;
|
|
6
|
+
/** Project-relative path to the frame's HTML sub-composition. */
|
|
7
|
+
src: string;
|
|
8
|
+
/** Time (seconds) to seek to for the poster. */
|
|
9
|
+
seconds: number;
|
|
10
|
+
title: string;
|
|
11
|
+
/** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
|
|
12
|
+
fit?: "cover" | "contain";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Server-rendered poster for a frame. The thumbnail route seeks the composition
|
|
17
|
+
* by time (at its real fps) and caches the result, so there's no live iframe,
|
|
18
|
+
* no postMessage seek, and no client-side fps assumption. Shared by the
|
|
19
|
+
* contact-sheet tile and the frame-focus view.
|
|
20
|
+
*/
|
|
21
|
+
export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
|
|
22
|
+
const [failed, setFailed] = useState(false);
|
|
23
|
+
// The <img> is reused (no key) when a tile/hero swaps to a different frame, so a
|
|
24
|
+
// prior load error would stick. Reset when the poster target changes.
|
|
25
|
+
useEffect(() => setFailed(false), [src, seconds]);
|
|
26
|
+
if (failed) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
|
|
29
|
+
Preview unavailable
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const url = buildCompositionThumbnailUrl({
|
|
34
|
+
previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
|
|
35
|
+
seekTime: seconds,
|
|
36
|
+
duration: 0,
|
|
37
|
+
origin: window.location.origin,
|
|
38
|
+
});
|
|
39
|
+
return (
|
|
40
|
+
<img
|
|
41
|
+
src={url}
|
|
42
|
+
alt={title}
|
|
43
|
+
draggable={false}
|
|
44
|
+
loading="lazy"
|
|
45
|
+
onError={() => setFailed(true)}
|
|
46
|
+
className={`h-full w-full ${fit === "contain" ? "object-contain" : "object-cover"}`}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Time (seconds) to show a frame at — past the intro so the key moment is visible. */
|
|
52
|
+
export function posterTime(frame: { poster?: number; durationSeconds?: number }): number {
|
|
53
|
+
if (frame.poster != null) return frame.poster;
|
|
54
|
+
if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
|
|
55
|
+
return 1.5;
|
|
56
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { StoryboardGlobals } from "@hyperframes/core/storyboard";
|
|
2
|
+
|
|
3
|
+
export interface StoryboardDirectionProps {
|
|
4
|
+
globals: StoryboardGlobals;
|
|
5
|
+
frameCount: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Global direction header: the message/thesis plus arc, audience, and format.
|
|
10
|
+
* This is the storyboard's "north star" pulled from the manifest frontmatter.
|
|
11
|
+
*/
|
|
12
|
+
export function StoryboardDirection({ globals, frameCount }: StoryboardDirectionProps) {
|
|
13
|
+
const meta = [
|
|
14
|
+
{ label: "Arc", value: globals.arc },
|
|
15
|
+
{ label: "Audience", value: globals.audience },
|
|
16
|
+
{ label: "Voice", value: globals.extra.voice },
|
|
17
|
+
{ label: "Format", value: globals.format },
|
|
18
|
+
{ label: "Frames", value: String(frameCount) },
|
|
19
|
+
].filter((item): item is { label: string; value: string } => Boolean(item.value));
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<header className="border-b border-neutral-800 pb-5">
|
|
23
|
+
<div className="text-[11px] font-medium uppercase tracking-wider text-neutral-500">
|
|
24
|
+
Storyboard
|
|
25
|
+
</div>
|
|
26
|
+
{globals.message ? (
|
|
27
|
+
<h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-100">
|
|
28
|
+
{globals.message}
|
|
29
|
+
</h1>
|
|
30
|
+
) : (
|
|
31
|
+
<h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-400">
|
|
32
|
+
Untitled storyboard
|
|
33
|
+
</h1>
|
|
34
|
+
)}
|
|
35
|
+
<dl className="mt-4 flex flex-wrap gap-x-8 gap-y-2">
|
|
36
|
+
{meta.map((item) => (
|
|
37
|
+
<div key={item.label} className="flex items-baseline gap-2">
|
|
38
|
+
<dt className="text-[11px] uppercase tracking-wider text-neutral-500">{item.label}</dt>
|
|
39
|
+
<dd className="text-sm text-neutral-300">{item.value}</dd>
|
|
40
|
+
</div>
|
|
41
|
+
))}
|
|
42
|
+
</dl>
|
|
43
|
+
</header>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard";
|
|
3
|
+
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
|
4
|
+
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
|
5
|
+
import { useViewMode } from "../../contexts/ViewModeContext";
|
|
6
|
+
import { FramePoster, posterTime } from "./FramePoster";
|
|
7
|
+
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
|
|
8
|
+
|
|
9
|
+
export interface StoryboardFrameFocusProps {
|
|
10
|
+
projectId: string;
|
|
11
|
+
/** Path to STORYBOARD.md (edits are written here). */
|
|
12
|
+
storyboardPath: string;
|
|
13
|
+
frame: StoryboardFrameView;
|
|
14
|
+
frameCount: number;
|
|
15
|
+
onBack: () => void;
|
|
16
|
+
onNavigate: (delta: number) => void;
|
|
17
|
+
/** Re-parse the manifest after an edit is saved. */
|
|
18
|
+
onSaved: () => void;
|
|
19
|
+
/** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
|
|
20
|
+
onSelectComposition: (path: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Full-area focus on a single frame: large poster, editable voiceover guide,
|
|
25
|
+
* status advancement, full narrative, and a jump into the live preview. Edits
|
|
26
|
+
* are written back to STORYBOARD.md in place (markdown stays canonical).
|
|
27
|
+
*
|
|
28
|
+
* Mounted with a `key` per frame, so `draft` initializes from the frame and a
|
|
29
|
+
* save-triggered reload never clobbers in-progress typing.
|
|
30
|
+
*/
|
|
31
|
+
// fallow-ignore-next-line complexity
|
|
32
|
+
export function StoryboardFrameFocus({
|
|
33
|
+
projectId,
|
|
34
|
+
storyboardPath,
|
|
35
|
+
frame,
|
|
36
|
+
frameCount,
|
|
37
|
+
onBack,
|
|
38
|
+
onNavigate,
|
|
39
|
+
onSaved,
|
|
40
|
+
onSelectComposition,
|
|
41
|
+
}: StoryboardFrameFocusProps) {
|
|
42
|
+
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
|
43
|
+
const { setViewMode } = useViewMode();
|
|
44
|
+
const [draft, setDraft] = useState(frame.voiceover ?? "");
|
|
45
|
+
const [busy, setBusy] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
const applyEdit = useCallback(
|
|
49
|
+
async (edit: (source: string) => string) => {
|
|
50
|
+
if (busy) return; // one read-modify-write at a time; avoids a lost update
|
|
51
|
+
setBusy(true);
|
|
52
|
+
setError(null);
|
|
53
|
+
try {
|
|
54
|
+
const source = await readProjectFile(storyboardPath);
|
|
55
|
+
await writeProjectFile(storyboardPath, edit(source));
|
|
56
|
+
onSaved();
|
|
57
|
+
} catch (err: unknown) {
|
|
58
|
+
setError(err instanceof Error ? err.message : "failed to save");
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[readProjectFile, writeProjectFile, storyboardPath, onSaved, busy],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const title = frame.title ?? `Frame ${frame.index}`;
|
|
67
|
+
const dirty = draft !== (frame.voiceover ?? "");
|
|
68
|
+
const canOpenPreview = frame.srcExists && Boolean(frame.src);
|
|
69
|
+
|
|
70
|
+
// Leaving the frame drops the in-memory voiceover draft; confirm when it's dirty.
|
|
71
|
+
const confirmLeave = () => !dirty || window.confirm("Discard unsaved voiceover changes?");
|
|
72
|
+
const handleBack = () => {
|
|
73
|
+
if (confirmLeave()) onBack();
|
|
74
|
+
};
|
|
75
|
+
const handleNavigate = (delta: number) => {
|
|
76
|
+
if (confirmLeave()) onNavigate(delta);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ←/→ navigate frames, Esc returns to the Board — but never while typing in a field.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
// fallow-ignore-next-line complexity
|
|
82
|
+
const onKey = (e: KeyboardEvent) => {
|
|
83
|
+
const el = document.activeElement;
|
|
84
|
+
if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return;
|
|
85
|
+
if (e.key === "Escape") handleBack();
|
|
86
|
+
else if (e.key === "ArrowLeft" && frame.index > 1) handleNavigate(-1);
|
|
87
|
+
else if (e.key === "ArrowRight" && frame.index < frameCount) handleNavigate(1);
|
|
88
|
+
};
|
|
89
|
+
window.addEventListener("keydown", onKey);
|
|
90
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const openInPreview = () => {
|
|
94
|
+
if (frame.src) onSelectComposition(frame.src);
|
|
95
|
+
setViewMode("timeline");
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
|
100
|
+
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={handleBack}
|
|
104
|
+
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800"
|
|
105
|
+
>
|
|
106
|
+
← Board
|
|
107
|
+
</button>
|
|
108
|
+
<span className="text-sm font-medium text-neutral-200">
|
|
109
|
+
Frame {frame.number ?? frame.index} — {title}
|
|
110
|
+
</span>
|
|
111
|
+
<div className="ml-auto flex items-center gap-1">
|
|
112
|
+
<NavButton
|
|
113
|
+
label="‹ Prev"
|
|
114
|
+
disabled={frame.index <= 1}
|
|
115
|
+
onClick={() => handleNavigate(-1)}
|
|
116
|
+
/>
|
|
117
|
+
<NavButton
|
|
118
|
+
label="Next ›"
|
|
119
|
+
disabled={frame.index >= frameCount}
|
|
120
|
+
onClick={() => handleNavigate(1)}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="flex flex-1 min-h-0">
|
|
126
|
+
<div className="flex w-3/5 min-w-0 items-center justify-center bg-neutral-900/40 p-8">
|
|
127
|
+
<div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
|
|
128
|
+
{canOpenPreview && frame.src ? (
|
|
129
|
+
<FramePoster
|
|
130
|
+
projectId={projectId}
|
|
131
|
+
src={frame.src}
|
|
132
|
+
seconds={posterTime(frame)}
|
|
133
|
+
title={title}
|
|
134
|
+
fit="contain"
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
<div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
|
|
138
|
+
{frame.status === "outline" ? "Not built yet" : "No preview"}
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div className="w-2/5 min-w-0 space-y-6 overflow-auto border-l border-neutral-800 px-6 py-5">
|
|
145
|
+
<StatusRow
|
|
146
|
+
status={frame.status}
|
|
147
|
+
busy={busy}
|
|
148
|
+
onSet={(s) => applyEdit((src) => setFrameStatus(src, frame.index, s))}
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
|
|
152
|
+
{frame.duration && <span>Duration {frame.duration}</span>}
|
|
153
|
+
{frame.transitionIn && <span>Transition {frame.transitionIn}</span>}
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<section>
|
|
157
|
+
<div className="mb-1 flex items-center justify-between">
|
|
158
|
+
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
|
159
|
+
🎙 Voiceover <span className="font-normal normal-case text-neutral-600">guide</span>
|
|
160
|
+
</h3>
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => applyEdit((src) => setFrameVoiceover(src, frame.index, draft))}
|
|
164
|
+
disabled={!dirty || busy}
|
|
165
|
+
className="rounded bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40"
|
|
166
|
+
>
|
|
167
|
+
Save
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
<textarea
|
|
171
|
+
value={draft}
|
|
172
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
173
|
+
rows={3}
|
|
174
|
+
placeholder="What the narrator says over this frame…"
|
|
175
|
+
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"
|
|
176
|
+
/>
|
|
177
|
+
<p className="mt-1 text-[11px] text-neutral-600">
|
|
178
|
+
A draft guide. SCRIPT.md locks the final narration that drives TTS.
|
|
179
|
+
</p>
|
|
180
|
+
{error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
|
|
181
|
+
</section>
|
|
182
|
+
|
|
183
|
+
{frame.narrative && (
|
|
184
|
+
<section>
|
|
185
|
+
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
|
186
|
+
Narrative
|
|
187
|
+
</h3>
|
|
188
|
+
<p className="whitespace-pre-wrap text-sm text-neutral-300">{frame.narrative}</p>
|
|
189
|
+
</section>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={openInPreview}
|
|
195
|
+
disabled={!canOpenPreview}
|
|
196
|
+
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"
|
|
197
|
+
>
|
|
198
|
+
Open in Preview →
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function NavButton({
|
|
207
|
+
label,
|
|
208
|
+
disabled,
|
|
209
|
+
onClick,
|
|
210
|
+
}: {
|
|
211
|
+
label: string;
|
|
212
|
+
disabled: boolean;
|
|
213
|
+
onClick: () => void;
|
|
214
|
+
}) {
|
|
215
|
+
return (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={onClick}
|
|
219
|
+
disabled={disabled}
|
|
220
|
+
className="rounded px-2 py-1 text-xs font-medium text-neutral-300 hover:bg-neutral-800 disabled:opacity-30"
|
|
221
|
+
>
|
|
222
|
+
{label}
|
|
223
|
+
</button>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function StatusRow({
|
|
228
|
+
status,
|
|
229
|
+
busy,
|
|
230
|
+
onSet,
|
|
231
|
+
}: {
|
|
232
|
+
status: FrameStatus;
|
|
233
|
+
busy: boolean;
|
|
234
|
+
onSet: (next: FrameStatus) => void;
|
|
235
|
+
}) {
|
|
236
|
+
return (
|
|
237
|
+
<div className="flex items-center gap-2">
|
|
238
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
|
239
|
+
Status
|
|
240
|
+
</span>
|
|
241
|
+
<div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5">
|
|
242
|
+
{FRAME_STATUS_ORDER.map((option) => (
|
|
243
|
+
<button
|
|
244
|
+
key={option}
|
|
245
|
+
type="button"
|
|
246
|
+
disabled={busy}
|
|
247
|
+
aria-pressed={status === option}
|
|
248
|
+
title={FRAME_STATUS_META[option].tooltip}
|
|
249
|
+
onClick={() => onSet(option)}
|
|
250
|
+
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
|
|
251
|
+
status === option
|
|
252
|
+
? "bg-neutral-700 text-neutral-100"
|
|
253
|
+
: "text-neutral-400 hover:text-neutral-200"
|
|
254
|
+
}`}
|
|
255
|
+
>
|
|
256
|
+
{FRAME_STATUS_META[option].label}
|
|
257
|
+
</button>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|