@hyperframes/studio 0.6.109 → 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/hyperframes-player-BRjQ5a5_.js +418 -0
- package/dist/assets/index-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CFCe0Xvn.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/gsapAnimationConstants.ts +24 -0
- package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
- 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/useGsapTweenCache.ts +0 -27
- package/src/hooks/useRazorSplit.ts +4 -10
- 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/src/utils/timelineElementSplit.test.ts +61 -1
- package/src/utils/timelineElementSplit.ts +18 -0
- package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
- package/dist/assets/index-BVqybwMG.css +0 -1
- package/dist/assets/index-ho_f4axK.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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
import DOMPurify from "dompurify";
|
|
4
|
+
import { SourceEditor } from "../editor/SourceEditor";
|
|
5
|
+
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
|
6
|
+
|
|
7
|
+
export interface SourceFile {
|
|
8
|
+
path: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StoryboardSourceEditorProps {
|
|
13
|
+
files: SourceFile[];
|
|
14
|
+
/** Called after a successful save so the Board can re-parse the updated file. */
|
|
15
|
+
onSaved: () => void;
|
|
16
|
+
/** Surfaces unsaved-edit state so the parent can guard the Board↔Source toggle. */
|
|
17
|
+
onDirtyChange?: (dirty: boolean) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DISCARD_PROMPT = "Discard unsaved markdown changes?";
|
|
21
|
+
|
|
22
|
+
interface EditableFile {
|
|
23
|
+
content: string;
|
|
24
|
+
setContent: (next: string) => void;
|
|
25
|
+
dirty: boolean;
|
|
26
|
+
loading: boolean;
|
|
27
|
+
saving: boolean;
|
|
28
|
+
error: string | null;
|
|
29
|
+
save: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Load a project file's raw text and track edits + save state via the shared file manager. */
|
|
33
|
+
function useEditableFile(path: string, onSaved: () => void): EditableFile {
|
|
34
|
+
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
|
35
|
+
const [content, setContent] = useState("");
|
|
36
|
+
const [saved, setSaved] = useState("");
|
|
37
|
+
const [loading, setLoading] = useState(true);
|
|
38
|
+
const [saving, setSaving] = useState(false);
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!path) return;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
setLoading(true);
|
|
45
|
+
setError(null);
|
|
46
|
+
readProjectFile(path)
|
|
47
|
+
.then((text) => {
|
|
48
|
+
if (cancelled) return;
|
|
49
|
+
setContent(text);
|
|
50
|
+
setSaved(text);
|
|
51
|
+
})
|
|
52
|
+
.catch((err: unknown) => {
|
|
53
|
+
if (!cancelled) setError(err instanceof Error ? err.message : "failed to load file");
|
|
54
|
+
})
|
|
55
|
+
.finally(() => {
|
|
56
|
+
if (!cancelled) setLoading(false);
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
};
|
|
61
|
+
}, [path, readProjectFile]);
|
|
62
|
+
|
|
63
|
+
const save = useCallback(() => {
|
|
64
|
+
if (saving) return; // coalesce a fast double Cmd+S into one PUT
|
|
65
|
+
setSaving(true);
|
|
66
|
+
setError(null);
|
|
67
|
+
writeProjectFile(path, content)
|
|
68
|
+
.then(() => {
|
|
69
|
+
setSaved(content);
|
|
70
|
+
onSaved();
|
|
71
|
+
})
|
|
72
|
+
.catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
|
|
73
|
+
.finally(() => setSaving(false));
|
|
74
|
+
}, [writeProjectFile, path, content, onSaved, saving]);
|
|
75
|
+
|
|
76
|
+
return { content, setContent, dirty: content !== saved, loading, saving, error, save };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Preview links open in a new tab with the `window.opener` back-channel severed. */
|
|
80
|
+
function hardenLinks(node: Element): void {
|
|
81
|
+
if (node.tagName === "A" && node.hasAttribute("href")) {
|
|
82
|
+
node.setAttribute("target", "_blank");
|
|
83
|
+
node.setAttribute("rel", "noopener noreferrer");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Render markdown to sanitized HTML, debounced so we don't re-parse on every keystroke. */
|
|
88
|
+
function useMarkdownPreview(source: string): string {
|
|
89
|
+
const [debounced, setDebounced] = useState(source);
|
|
90
|
+
const primed = useRef(false);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
// Paint the first non-empty content immediately (no 200ms blank window after a file
|
|
93
|
+
// loads), exactly once, then debounce all subsequent keystrokes.
|
|
94
|
+
if (!primed.current && source !== "") {
|
|
95
|
+
primed.current = true;
|
|
96
|
+
setDebounced(source);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const id = window.setTimeout(() => setDebounced(source), 200);
|
|
100
|
+
return () => window.clearTimeout(id);
|
|
101
|
+
}, [source]);
|
|
102
|
+
return useMemo(() => {
|
|
103
|
+
// `{ async: false }` pins the synchronous string return (no Promise union to narrow).
|
|
104
|
+
const html = marked.parse(debounced, { async: false });
|
|
105
|
+
// Scope the link-hardening hook to this call; `finally` guarantees removal even if
|
|
106
|
+
// `sanitize` throws, so the hook can never leak into other DOMPurify consumers.
|
|
107
|
+
DOMPurify.addHook("afterSanitizeAttributes", hardenLinks);
|
|
108
|
+
try {
|
|
109
|
+
return DOMPurify.sanitize(html);
|
|
110
|
+
} finally {
|
|
111
|
+
DOMPurify.removeHook("afterSanitizeAttributes");
|
|
112
|
+
}
|
|
113
|
+
}, [debounced]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isSaveShortcut(event: React.KeyboardEvent): boolean {
|
|
117
|
+
return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Minimal prose styling for the rendered preview (Tailwind doesn't style raw HTML).
|
|
121
|
+
const PREVIEW_PROSE =
|
|
122
|
+
"text-sm leading-relaxed text-neutral-300 " +
|
|
123
|
+
"[&_h1]:mb-2 [&_h1]:mt-5 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:text-neutral-100 " +
|
|
124
|
+
"[&_h2]:mb-1.5 [&_h2]:mt-5 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-neutral-100 " +
|
|
125
|
+
"[&_h3]:mt-4 [&_h3]:font-semibold [&_h3]:text-neutral-200 " +
|
|
126
|
+
"[&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 " +
|
|
127
|
+
"[&_code]:rounded [&_code]:bg-neutral-800 [&_code]:px-1 [&_code]:text-[0.85em] [&_code]:text-neutral-200 " +
|
|
128
|
+
"[&_pre]:my-3 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-3 " +
|
|
129
|
+
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
|
|
130
|
+
"[&_hr]:my-4 [&_hr]:border-neutral-800 [&_a]:text-sky-400 [&_strong]:text-neutral-100 " +
|
|
131
|
+
"[&_img]:my-2 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded " +
|
|
132
|
+
"[&_table]:my-3 [&_th]:border [&_th]:border-neutral-800 [&_th]:px-2 [&_th]:py-1 " +
|
|
133
|
+
"[&_td]:border [&_td]:border-neutral-800 [&_td]:px-2 [&_td]:py-1";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Raw markdown editor + live preview for the storyboard's canonical files
|
|
137
|
+
* (STORYBOARD.md / SCRIPT.md). Markdown stays the source of truth: saving writes
|
|
138
|
+
* the file and re-parses the Board. Deliberately raw (not WYSIWYG) so the
|
|
139
|
+
* structured frame fields can't be mangled; the preview is sanitized before render.
|
|
140
|
+
*/
|
|
141
|
+
// fallow-ignore-next-line complexity
|
|
142
|
+
export function StoryboardSourceEditor({
|
|
143
|
+
files,
|
|
144
|
+
onSaved,
|
|
145
|
+
onDirtyChange,
|
|
146
|
+
}: StoryboardSourceEditorProps) {
|
|
147
|
+
const [selected, setSelected] = useState(files[0]?.path ?? "");
|
|
148
|
+
// Reconcile against the current file list so a removed/renamed file can't strand the tab.
|
|
149
|
+
const activePath = files.some((f) => f.path === selected) ? selected : (files[0]?.path ?? "");
|
|
150
|
+
const file = useEditableFile(activePath, onSaved);
|
|
151
|
+
const previewHtml = useMarkdownPreview(file.content);
|
|
152
|
+
|
|
153
|
+
// Surface dirty state to the parent (guards the Board↔Source toggle) and warn
|
|
154
|
+
// on browser-level navigation while there are unsaved edits.
|
|
155
|
+
useEffect(() => onDirtyChange?.(file.dirty), [onDirtyChange, file.dirty]);
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!file.dirty) return;
|
|
158
|
+
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
event.returnValue = "";
|
|
161
|
+
};
|
|
162
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
163
|
+
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
164
|
+
}, [file.dirty]);
|
|
165
|
+
|
|
166
|
+
// Switching files discards the in-memory buffer; confirm when there are unsaved edits.
|
|
167
|
+
const selectFile = (path: string) => {
|
|
168
|
+
if (path === activePath) return;
|
|
169
|
+
if (file.dirty && !window.confirm(DISCARD_PROMPT)) return;
|
|
170
|
+
setSelected(path);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
className="flex flex-1 min-h-0 flex-col"
|
|
176
|
+
onKeyDown={(e) => {
|
|
177
|
+
if (!isSaveShortcut(e)) return;
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
if (file.dirty && !file.saving) file.save();
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<div className="flex items-center gap-1 border-b border-neutral-800 px-4 py-2">
|
|
183
|
+
{files.map((f) => (
|
|
184
|
+
<button
|
|
185
|
+
key={f.path}
|
|
186
|
+
type="button"
|
|
187
|
+
onClick={() => selectFile(f.path)}
|
|
188
|
+
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
189
|
+
activePath === f.path
|
|
190
|
+
? "bg-neutral-800 text-neutral-100"
|
|
191
|
+
: "text-neutral-400 hover:text-neutral-200"
|
|
192
|
+
}`}
|
|
193
|
+
>
|
|
194
|
+
{f.label}
|
|
195
|
+
</button>
|
|
196
|
+
))}
|
|
197
|
+
<div className="ml-auto flex items-center gap-3">
|
|
198
|
+
{file.error && <span className="text-xs text-red-400">{file.error}</span>}
|
|
199
|
+
<span className="text-xs text-neutral-500">
|
|
200
|
+
{file.saving ? "Saving…" : file.dirty ? "Unsaved changes" : "Saved"}
|
|
201
|
+
</span>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
onClick={file.save}
|
|
205
|
+
disabled={!file.dirty || file.saving}
|
|
206
|
+
className="rounded bg-emerald-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-40"
|
|
207
|
+
>
|
|
208
|
+
Save
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="flex flex-1 min-h-0">
|
|
213
|
+
<div className="w-1/2 min-w-0 border-r border-neutral-800">
|
|
214
|
+
{file.loading ? (
|
|
215
|
+
<div className="p-4 text-sm text-neutral-500">Loading {activePath}…</div>
|
|
216
|
+
) : (
|
|
217
|
+
<SourceEditor
|
|
218
|
+
content={file.content}
|
|
219
|
+
language="markdown"
|
|
220
|
+
filePath={activePath}
|
|
221
|
+
onChange={file.setContent}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
<div className="w-1/2 min-w-0 overflow-auto bg-neutral-950 px-6 py-4">
|
|
226
|
+
<div className={PREVIEW_PROSE} dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Explains the frame lifecycle: a frame advances outline → built → animated.
|
|
5
|
+
* Mirrors the status chips on each tile (shares FRAME_STATUS_META).
|
|
6
|
+
*/
|
|
7
|
+
export function StoryboardStatusLegend() {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-neutral-500">
|
|
10
|
+
<span className="uppercase tracking-wider text-neutral-600">Status</span>
|
|
11
|
+
{FRAME_STATUS_ORDER.map((status, i) => (
|
|
12
|
+
<span key={status} className="flex items-center gap-1.5">
|
|
13
|
+
{i > 0 && <span className="text-neutral-700">→</span>}
|
|
14
|
+
<span className={`h-2 w-2 rounded-full ${FRAME_STATUS_META[status].dotClass}`} />
|
|
15
|
+
<span className="font-medium text-neutral-300">{FRAME_STATUS_META[status].label}</span>
|
|
16
|
+
<span className="text-neutral-500">— {FRAME_STATUS_META[status].description}</span>
|
|
17
|
+
</span>
|
|
18
|
+
))}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useStoryboard } from "../../hooks/useStoryboard";
|
|
3
|
+
import { StoryboardLoaded } from "./StoryboardLoaded";
|
|
4
|
+
|
|
5
|
+
export interface StoryboardViewProps {
|
|
6
|
+
projectId: string;
|
|
7
|
+
/** Select a composition in the timeline (used by the frame focus "Open in Preview"). */
|
|
8
|
+
onSelectComposition: (path: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Top-level storyboard stage. Replaces the timeline/preview when the view mode
|
|
13
|
+
* is `storyboard`. Handles the load states here; once a storyboard exists,
|
|
14
|
+
* {@link StoryboardLoaded} owns the Board ↔ Source experience.
|
|
15
|
+
*/
|
|
16
|
+
// fallow-ignore-next-line complexity
|
|
17
|
+
export function StoryboardView({ projectId, onSelectComposition }: StoryboardViewProps) {
|
|
18
|
+
const { data, loading, error, reload } = useStoryboard(projectId);
|
|
19
|
+
|
|
20
|
+
if (loading) return <StoryboardFrame>{<Message>Loading storyboard…</Message>}</StoryboardFrame>;
|
|
21
|
+
if (error) {
|
|
22
|
+
return (
|
|
23
|
+
<StoryboardFrame>
|
|
24
|
+
<Message tone="error">Couldn’t load the storyboard: {error}</Message>
|
|
25
|
+
</StoryboardFrame>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (!data) return <StoryboardFrame>{null}</StoryboardFrame>;
|
|
29
|
+
if (!data.exists) {
|
|
30
|
+
return (
|
|
31
|
+
<StoryboardFrame>
|
|
32
|
+
<EmptyState path={data.path} />
|
|
33
|
+
</StoryboardFrame>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<StoryboardLoaded
|
|
39
|
+
projectId={projectId}
|
|
40
|
+
data={data}
|
|
41
|
+
reload={reload}
|
|
42
|
+
onSelectComposition={onSelectComposition}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function StoryboardFrame({ children }: { children: ReactNode }) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="flex-1 min-h-0 overflow-auto bg-neutral-950 text-neutral-200">
|
|
50
|
+
<div className="mx-auto max-w-[1400px] px-8 py-8">{children}</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Message({ children, tone = "muted" }: { children: ReactNode; tone?: "muted" | "error" }) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={`px-6 py-12 text-center text-sm ${
|
|
59
|
+
tone === "error" ? "text-red-400" : "text-neutral-500"
|
|
60
|
+
}`}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function EmptyState({ path }: { path: string }) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="rounded-lg border border-dashed border-neutral-800 px-6 py-16 text-center">
|
|
70
|
+
<h2 className="text-base font-semibold text-neutral-300">No storyboard yet</h2>
|
|
71
|
+
<p className="mx-auto mt-2 max-w-md text-sm text-neutral-500">
|
|
72
|
+
Add a <code className="rounded bg-neutral-900 px-1 py-0.5 text-neutral-400">{path}</code> at
|
|
73
|
+
the project root to plan this video frame by frame. Your agent can create and iterate on it
|
|
74
|
+
for you.
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FrameStatus } from "@hyperframes/core/storyboard";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single source of truth for how each frame lifecycle status is presented —
|
|
5
|
+
* label, tooltip, description, and the chip/dot color classes — so the tile
|
|
6
|
+
* chip and the legend dot can't drift apart.
|
|
7
|
+
*/
|
|
8
|
+
export const FRAME_STATUS_META: Record<
|
|
9
|
+
FrameStatus,
|
|
10
|
+
{ label: string; tooltip: string; description: string; chipClass: string; dotClass: string }
|
|
11
|
+
> = {
|
|
12
|
+
outline: {
|
|
13
|
+
label: "Outline",
|
|
14
|
+
tooltip: "Planned in text — no HTML frame built yet.",
|
|
15
|
+
description: "Planned in text. Story and intent exist; the visual isn’t built.",
|
|
16
|
+
chipClass: "bg-neutral-800 text-neutral-300",
|
|
17
|
+
dotClass: "bg-neutral-500",
|
|
18
|
+
},
|
|
19
|
+
built: {
|
|
20
|
+
label: "Built",
|
|
21
|
+
tooltip: "Static HTML frame built — not animated yet.",
|
|
22
|
+
description: "The HTML frame exists as a static key moment. Look is locked; motion isn’t.",
|
|
23
|
+
chipClass: "bg-sky-500/20 text-sky-300",
|
|
24
|
+
dotClass: "bg-sky-400",
|
|
25
|
+
},
|
|
26
|
+
animated: {
|
|
27
|
+
label: "Animated",
|
|
28
|
+
tooltip: "Keyframed and animated — plays in the final cut.",
|
|
29
|
+
description: "Keyframed into motion. This is the file stitched into the final video.",
|
|
30
|
+
chipClass: "bg-emerald-500/20 text-emerald-300",
|
|
31
|
+
dotClass: "bg-emerald-400",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The lifecycle order an agent advances each frame through. */
|
|
36
|
+
export const FRAME_STATUS_ORDER: FrameStatus[] = ["outline", "built", "animated"];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Top-level Studio view mode.
|
|
13
|
+
*
|
|
14
|
+
* `timeline` is the existing NLE/preview stage. `storyboard` replaces that stage
|
|
15
|
+
* with the storyboard contact sheet. The mode is mirrored to the `?view=` query
|
|
16
|
+
* param so it survives reloads and — importantly — so an agent can deep-link the
|
|
17
|
+
* user straight into the storyboard by navigating the tab to `?view=storyboard`.
|
|
18
|
+
*/
|
|
19
|
+
export type StudioViewMode = "timeline" | "storyboard";
|
|
20
|
+
|
|
21
|
+
const VIEW_QUERY_PARAM = "view";
|
|
22
|
+
|
|
23
|
+
function readViewModeFromUrl(): StudioViewMode {
|
|
24
|
+
if (typeof window === "undefined") return "timeline";
|
|
25
|
+
return new URLSearchParams(window.location.search).get(VIEW_QUERY_PARAM) === "storyboard"
|
|
26
|
+
? "storyboard"
|
|
27
|
+
: "timeline";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeViewModeToUrl(mode: StudioViewMode): void {
|
|
31
|
+
if (typeof window === "undefined") return;
|
|
32
|
+
const url = new URL(window.location.href);
|
|
33
|
+
if (mode === "storyboard") {
|
|
34
|
+
url.searchParams.set(VIEW_QUERY_PARAM, "storyboard");
|
|
35
|
+
} else {
|
|
36
|
+
url.searchParams.delete(VIEW_QUERY_PARAM);
|
|
37
|
+
}
|
|
38
|
+
window.history.replaceState(window.history.state, "", url);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ViewModeValue {
|
|
42
|
+
viewMode: StudioViewMode;
|
|
43
|
+
setViewMode: (mode: StudioViewMode) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Owns the view-mode state. When `enabled` is false (storyboard flag off) the
|
|
48
|
+
* mode is pinned to `timeline` and the URL is left untouched, so the feature is
|
|
49
|
+
* fully inert until the flag is on.
|
|
50
|
+
*/
|
|
51
|
+
export function useViewModeState(enabled: boolean): ViewModeValue {
|
|
52
|
+
const [viewMode, setMode] = useState<StudioViewMode>(() =>
|
|
53
|
+
enabled ? readViewModeFromUrl() : "timeline",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Reflect genuine browser back/forward between history entries with a different
|
|
57
|
+
// `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
|
|
58
|
+
// `popstate`, so this listener never sees them — `setViewMode` updates state directly.
|
|
59
|
+
// An agent deep-links by doing a full navigation to `?view=storyboard` (picked up by
|
|
60
|
+
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
|
|
61
|
+
// reflected here, by design.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!enabled) return;
|
|
64
|
+
const onPopState = () => setMode(readViewModeFromUrl());
|
|
65
|
+
window.addEventListener("popstate", onPopState);
|
|
66
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
67
|
+
}, [enabled]);
|
|
68
|
+
|
|
69
|
+
const setViewMode = useCallback(
|
|
70
|
+
(mode: StudioViewMode) => {
|
|
71
|
+
if (!enabled) return;
|
|
72
|
+
setMode(mode);
|
|
73
|
+
writeViewModeToUrl(mode);
|
|
74
|
+
},
|
|
75
|
+
[enabled],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const effectiveMode = enabled ? viewMode : "timeline";
|
|
79
|
+
return useMemo(() => ({ viewMode: effectiveMode, setViewMode }), [effectiveMode, setViewMode]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ViewModeContext = createContext<ViewModeValue | null>(null);
|
|
83
|
+
|
|
84
|
+
export function useViewMode(): ViewModeValue {
|
|
85
|
+
const ctx = useContext(ViewModeContext);
|
|
86
|
+
if (!ctx) throw new Error("useViewMode must be used within ViewModeProvider");
|
|
87
|
+
return ctx;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ViewModeProvider({
|
|
91
|
+
value,
|
|
92
|
+
children,
|
|
93
|
+
}: {
|
|
94
|
+
value: ViewModeValue;
|
|
95
|
+
children: ReactNode;
|
|
96
|
+
}) {
|
|
97
|
+
return <ViewModeContext value={value}>{children}</ViewModeContext>;
|
|
98
|
+
}
|
|
@@ -2,8 +2,6 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
-
import type { ShadowGsapOp } from "../utils/sdkShadow";
|
|
6
|
-
import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
|
|
7
5
|
|
|
8
6
|
export interface MutationResult {
|
|
9
7
|
ok: boolean;
|
|
@@ -28,10 +26,6 @@ export interface CommitMutationOptions {
|
|
|
28
26
|
* (and under distinct keys) run concurrently as before.
|
|
29
27
|
*/
|
|
30
28
|
serializeKey?: string;
|
|
31
|
-
/** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
|
|
32
|
-
shadowGsapOp?: ShadowGsapOp;
|
|
33
|
-
/** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
|
|
34
|
-
shadowKeyframeOp?: ShadowKeyframeOp;
|
|
35
29
|
}
|
|
36
30
|
|
|
37
31
|
export type CommitMutation = (
|
|
@@ -70,6 +64,9 @@ export interface GsapScriptCommitsParams {
|
|
|
70
64
|
onCacheInvalidate: () => void;
|
|
71
65
|
onFileContentChanged?: (path: string, content: string) => void;
|
|
72
66
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
73
|
-
/** Stage 7
|
|
67
|
+
/** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
|
|
74
68
|
sdkSession?: Composition | null;
|
|
69
|
+
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
|
70
|
+
/** Resync the in-memory SDK session after a server-authoritative write. */
|
|
71
|
+
forceReloadSdkSession?: () => void;
|
|
75
72
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
hashContent,
|
|
4
|
+
markSelfWrite,
|
|
5
|
+
isSelfWriteEcho,
|
|
6
|
+
resetSelfWriteRegistry,
|
|
7
|
+
} from "./sdkSelfWriteRegistry";
|
|
8
|
+
import { shouldReloadOnFileChange } from "./useSdkSession";
|
|
9
|
+
|
|
10
|
+
describe("sdkSelfWriteRegistry (finding #14)", () => {
|
|
11
|
+
beforeEach(() => resetSelfWriteRegistry());
|
|
12
|
+
|
|
13
|
+
it("recognizes the echo of bytes we just wrote", () => {
|
|
14
|
+
markSelfWrite("/comp.html", "<html>A</html>");
|
|
15
|
+
expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("does NOT match different content on the same path (an undo's reverted bytes)", () => {
|
|
19
|
+
markSelfWrite("/comp.html", "<html>A</html>");
|
|
20
|
+
expect(isSelfWriteEcho("/comp.html", "<html>REVERTED</html>")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("is keyed per file — a self-write to one file can't mask a change to another", () => {
|
|
24
|
+
markSelfWrite("/a.html", "<html>A</html>");
|
|
25
|
+
expect(isSelfWriteEcho("/b.html", "<html>A</html>")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("consumes a matched entry so a later genuine external write isn't suppressed", () => {
|
|
29
|
+
markSelfWrite("/comp.html", "<html>A</html>");
|
|
30
|
+
expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(true);
|
|
31
|
+
// A second arrival of identical bytes is NOT our echo — must reload.
|
|
32
|
+
expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("expires entries past the TTL so a stale self-write can't suppress forever", () => {
|
|
36
|
+
const t0 = 1_000_000;
|
|
37
|
+
markSelfWrite("/comp.html", "<html>A</html>", t0);
|
|
38
|
+
// 3 s later (> 2 s TTL) the entry is gone.
|
|
39
|
+
expect(isSelfWriteEcho("/comp.html", "<html>A</html>", t0 + 3000)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("hashes are stable and distinguish different content", () => {
|
|
43
|
+
expect(hashContent("x")).toBe(hashContent("x"));
|
|
44
|
+
expect(hashContent("x")).not.toBe(hashContent("y"));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("shouldReloadOnFileChange (finding #14)", () => {
|
|
49
|
+
beforeEach(() => resetSelfWriteRegistry());
|
|
50
|
+
|
|
51
|
+
it("suppresses the reload when content matches a registered self-write (cutover echo)", () => {
|
|
52
|
+
markSelfWrite("/comp.html", "<html>SELF</html>");
|
|
53
|
+
expect(shouldReloadOnFileChange("/comp.html", "<html>SELF</html>", true)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reloads on an undo write even inside the suppress window (content differs)", () => {
|
|
57
|
+
// The cutover registered SELF; the undo writes REVERTED bytes within the
|
|
58
|
+
// same 2 s window. Time-only suppression dropped this; identity reloads it.
|
|
59
|
+
markSelfWrite("/comp.html", "<html>SELF</html>");
|
|
60
|
+
expect(shouldReloadOnFileChange("/comp.html", "<html>REVERTED</html>", true)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the time window only when content is unavailable", () => {
|
|
64
|
+
expect(shouldReloadOnFileChange("/comp.html", null, true)).toBe(false);
|
|
65
|
+
expect(shouldReloadOnFileChange("/comp.html", null, false)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-write identity registry — discriminates an SDK cutover ECHO from a genuine
|
|
3
|
+
* external write (notably undo/redo) in the file-change reload-suppression path.
|
|
4
|
+
*
|
|
5
|
+
* The old suppression was purely time-based: any file-change within 2 s of the
|
|
6
|
+
* shared `domEditSaveTimestampRef` was swallowed. But BOTH an SDK cutover
|
|
7
|
+
* self-write AND an undo write set that same timestamp, so the window could not
|
|
8
|
+
* tell "the echo of the bytes I just wrote" (suppress) from "the reverted bytes
|
|
9
|
+
* an undo just wrote" (must reload). An undo that landed inside the window was
|
|
10
|
+
* silently dropped, leaving the in-memory SDK doc on stale pre-undo content.
|
|
11
|
+
*
|
|
12
|
+
* Fix: tag each cutover self-write with the CONTENT it wrote (by hash). A
|
|
13
|
+
* file-change reload is suppressed only when the new on-disk content matches a
|
|
14
|
+
* recently-registered self-write hash — i.e. it is provably our own echo. Undo
|
|
15
|
+
* writes are never registered (they don't flow through persistSdkSerialize), so
|
|
16
|
+
* their content won't match and the reload always fires. Identity, not a clock.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SELF_WRITE_TTL_MS = 2000;
|
|
20
|
+
|
|
21
|
+
interface SelfWriteEntry {
|
|
22
|
+
hash: string;
|
|
23
|
+
at: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Module-scoped: the studio process has a single SDK session lifecycle at a time
|
|
27
|
+
// and persists are funnelled through one persistSdkSerialize. Keyed by file path
|
|
28
|
+
// so a self-write to one file can't mask a real external change to another.
|
|
29
|
+
const registry = new Map<string, SelfWriteEntry[]>();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stable 32-bit FNV-1a hash of content. Collisions only risk SUPPRESSING a real
|
|
33
|
+
* reload, and only within the 2 s TTL for the exact same file — negligible, and
|
|
34
|
+
* strictly safer than the prior time-only window it replaces.
|
|
35
|
+
*/
|
|
36
|
+
export function hashContent(content: string): string {
|
|
37
|
+
let h = 0x811c9dc5;
|
|
38
|
+
for (let i = 0; i < content.length; i++) {
|
|
39
|
+
h ^= content.charCodeAt(i);
|
|
40
|
+
h = Math.imul(h, 0x01000193);
|
|
41
|
+
}
|
|
42
|
+
return (h >>> 0).toString(16);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function prune(entries: SelfWriteEntry[], now: number): SelfWriteEntry[] {
|
|
46
|
+
return entries.filter((e) => now - e.at < SELF_WRITE_TTL_MS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Record that WE wrote `content` to `path` (an SDK cutover self-write). */
|
|
50
|
+
export function markSelfWrite(path: string, content: string, now: number = Date.now()): void {
|
|
51
|
+
const next = prune(registry.get(path) ?? [], now);
|
|
52
|
+
next.push({ hash: hashContent(content), at: now });
|
|
53
|
+
registry.set(path, next);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when `content` matches a self-write registered for `path` within the TTL.
|
|
58
|
+
* Consumes the matched entry so a later genuinely-external write of identical
|
|
59
|
+
* bytes isn't suppressed forever.
|
|
60
|
+
*/
|
|
61
|
+
export function isSelfWriteEcho(path: string, content: string, now: number = Date.now()): boolean {
|
|
62
|
+
const entries = prune(registry.get(path) ?? [], now);
|
|
63
|
+
const hash = hashContent(content);
|
|
64
|
+
const idx = entries.findIndex((e) => e.hash === hash);
|
|
65
|
+
if (idx === -1) {
|
|
66
|
+
registry.set(path, entries);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
entries.splice(idx, 1);
|
|
70
|
+
registry.set(path, entries);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Test-only: drop all registered self-writes. */
|
|
75
|
+
export function resetSelfWriteRegistry(): void {
|
|
76
|
+
registry.clear();
|
|
77
|
+
}
|
|
@@ -97,6 +97,7 @@ export interface PersistTimelineEditInput {
|
|
|
97
97
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
98
98
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
99
99
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
100
|
+
coalesceKey?: string;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
|
|
@@ -119,6 +120,7 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
|
|
|
119
120
|
projectId: input.projectId,
|
|
120
121
|
label: input.label,
|
|
121
122
|
kind: "timeline",
|
|
123
|
+
coalesceKey: input.coalesceKey,
|
|
122
124
|
files: { [targetPath]: patchedContent },
|
|
123
125
|
readFile: async () => originalContent,
|
|
124
126
|
writeFile: input.writeProjectFile,
|