@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
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import type { MutableRefObject } from "react";
|
|
2
3
|
import { openComposition } from "@hyperframes/sdk";
|
|
3
4
|
import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
|
|
4
5
|
import type { Composition } from "@hyperframes/sdk";
|
|
5
6
|
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
7
|
+
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* True when an external file-change payload targets the active composition and
|
|
@@ -20,33 +22,88 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
|
|
|
20
22
|
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
|
21
23
|
* re-opens it when the active composition file changes on disk (code editor,
|
|
22
24
|
* agent, or server-side patch) so the in-memory linkedom document never goes
|
|
23
|
-
* stale.
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
* stale. The session has NO persist queue — Studio is the sole file writer; see
|
|
26
|
+
* the open effect below.
|
|
27
|
+
*/
|
|
28
|
+
// Reload-suppression baseline: a file-change within this window of our own SDK
|
|
29
|
+
// cutover write is a CANDIDATE echo, but the decision is content-identity based
|
|
30
|
+
// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
|
|
31
|
+
// still reloads (its reverted bytes were never registered as a self-write). The
|
|
32
|
+
// window only bounds how long a registered self-write stays suppressible.
|
|
33
|
+
const SELF_WRITE_SUPPRESS_MS = 2000;
|
|
34
|
+
|
|
35
|
+
/** Best-effort read of the changed file's content from a file-change payload. */
|
|
36
|
+
function readFileChangeContent(payload: unknown): string | null {
|
|
37
|
+
if (!payload || typeof payload !== "object") return null;
|
|
38
|
+
const record = payload as Record<string, unknown>;
|
|
39
|
+
if (typeof record.content === "string") return record.content;
|
|
40
|
+
if ("data" in record) return readFileChangeContent(record.data);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether a file-change for the active composition should reload the SDK
|
|
46
|
+
* session. `content` is the new on-disk bytes (from the payload or a re-read);
|
|
47
|
+
* pass null when unavailable. Content-identity wins: a change whose bytes match a
|
|
48
|
+
* registered self-write is our own echo (suppress). Without content we can't prove
|
|
49
|
+
* identity, so we fall back to the time window ONLY to suppress an echo — an undo
|
|
50
|
+
* write outside the window (or any non-self-write) still reloads. Exported for test.
|
|
32
51
|
*/
|
|
52
|
+
export function shouldReloadOnFileChange(
|
|
53
|
+
activeCompPath: string,
|
|
54
|
+
content: string | null,
|
|
55
|
+
withinSuppressWindow: boolean,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (content != null) return !isSelfWriteEcho(activeCompPath, content);
|
|
58
|
+
// No content to compare — preserve the old time-window echo suppression.
|
|
59
|
+
return !withinSuppressWindow;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SdkSessionHandle {
|
|
63
|
+
session: Composition | null;
|
|
64
|
+
/**
|
|
65
|
+
* Force a session reload immediately, bypassing the self-write suppress
|
|
66
|
+
* window. Call after undo/redo writes the active composition file so the
|
|
67
|
+
* SDK in-memory document reflects the reverted content.
|
|
68
|
+
*/
|
|
69
|
+
forceReload: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
export function useSdkSession(
|
|
34
73
|
projectId: string | null,
|
|
35
74
|
activeCompPath: string | null,
|
|
36
|
-
|
|
75
|
+
domEditSaveTimestampRef?: MutableRefObject<number>,
|
|
76
|
+
): SdkSessionHandle {
|
|
37
77
|
const [session, setSession] = useState<Composition | null>(null);
|
|
38
78
|
const [reloadToken, setReloadToken] = useState(0);
|
|
39
79
|
|
|
40
80
|
// ── Re-open on external change to the active composition ──
|
|
41
81
|
useEffect(() => {
|
|
42
82
|
if (!activeCompPath) return;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
83
|
+
const compPath = activeCompPath;
|
|
84
|
+
const readAdapter =
|
|
85
|
+
projectId != null
|
|
86
|
+
? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` })
|
|
87
|
+
: null;
|
|
46
88
|
const handler = (payload?: unknown) => {
|
|
47
|
-
if (shouldReloadSdkSession(payload,
|
|
48
|
-
|
|
89
|
+
if (!shouldReloadSdkSession(payload, compPath)) return;
|
|
90
|
+
const withinWindow =
|
|
91
|
+
!!domEditSaveTimestampRef &&
|
|
92
|
+
Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
|
|
93
|
+
const decide = (content: string | null) => {
|
|
94
|
+
if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
|
|
95
|
+
};
|
|
96
|
+
const payloadContent = readFileChangeContent(payload);
|
|
97
|
+
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
|
|
98
|
+
// (an undo's reverted bytes won't match a registered self-write → reload).
|
|
99
|
+
if (payloadContent != null || !readAdapter) {
|
|
100
|
+
decide(payloadContent);
|
|
101
|
+
return;
|
|
49
102
|
}
|
|
103
|
+
readAdapter
|
|
104
|
+
.read(compPath)
|
|
105
|
+
.then((c) => decide(typeof c === "string" ? c : null))
|
|
106
|
+
.catch(() => decide(null));
|
|
50
107
|
};
|
|
51
108
|
if (import.meta.hot) {
|
|
52
109
|
import.meta.hot.on("hf:file-change", handler);
|
|
@@ -56,7 +113,8 @@ export function useSdkSession(
|
|
|
56
113
|
const es = new EventSource("/api/events");
|
|
57
114
|
es.addEventListener("file-change", handler);
|
|
58
115
|
return () => es.close();
|
|
59
|
-
|
|
116
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
117
|
+
}, [activeCompPath, projectId]);
|
|
60
118
|
|
|
61
119
|
// ── Open / re-open the session ──
|
|
62
120
|
useEffect(() => {
|
|
@@ -66,7 +124,7 @@ export function useSdkSession(
|
|
|
66
124
|
}
|
|
67
125
|
|
|
68
126
|
let cancelled = false;
|
|
69
|
-
|
|
127
|
+
const compRef = { current: null as Composition | null };
|
|
70
128
|
|
|
71
129
|
const adapter = createHttpAdapter({
|
|
72
130
|
projectFilesUrl: `/api/projects/${projectId}`,
|
|
@@ -75,15 +133,21 @@ export function useSdkSession(
|
|
|
75
133
|
.read(activeCompPath)
|
|
76
134
|
.then(async (content) => {
|
|
77
135
|
if (cancelled || typeof content !== "string") return;
|
|
78
|
-
// No persist
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
136
|
+
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
137
|
+
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
138
|
+
// queue too would double-write the file (queue auto-writes on every
|
|
139
|
+
// 'change' AND Studio writes explicitly) and race on disk; it would
|
|
140
|
+
// also write the full active-composition serialization to the fixed
|
|
141
|
+
// persistPath even when an edit targeted a sub-composition file.
|
|
142
|
+
// Studio's editHistory is the authoritative undo stack — SDK history
|
|
143
|
+
// is unused dead weight here (forceReloadSdkSession discards it on undo).
|
|
144
|
+
const comp = await openComposition(content, { history: false });
|
|
82
145
|
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
|
83
146
|
if (cancelled) {
|
|
84
147
|
comp.dispose();
|
|
85
148
|
return;
|
|
86
149
|
}
|
|
150
|
+
compRef.current = comp;
|
|
87
151
|
setSession(comp);
|
|
88
152
|
})
|
|
89
153
|
.catch(() => {
|
|
@@ -92,10 +156,12 @@ export function useSdkSession(
|
|
|
92
156
|
|
|
93
157
|
return () => {
|
|
94
158
|
cancelled = true;
|
|
95
|
-
|
|
96
|
-
|
|
159
|
+
// No queue to flush; dispose only. (Flushing here would serialize the
|
|
160
|
+
// pre-undo in-memory doc and race the revert write on undo/redo reload.)
|
|
161
|
+
compRef.current?.dispose();
|
|
97
162
|
};
|
|
98
163
|
}, [projectId, activeCompPath, reloadToken]);
|
|
99
164
|
|
|
100
|
-
|
|
165
|
+
const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
|
|
166
|
+
return { session, forceReload };
|
|
101
167
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
StoryboardFrame,
|
|
4
|
+
StoryboardGlobals,
|
|
5
|
+
StoryboardWarning,
|
|
6
|
+
} from "@hyperframes/core/storyboard";
|
|
7
|
+
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
8
|
+
|
|
9
|
+
/** A frame as returned by the API: parsed frame + disk-resolution info. */
|
|
10
|
+
export interface StoryboardFrameView extends StoryboardFrame {
|
|
11
|
+
/** Whether `src` resolves to an existing file inside the project. */
|
|
12
|
+
srcExists: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The companion narration script (SCRIPT.md), when present alongside the storyboard. */
|
|
16
|
+
export interface StoryboardScript {
|
|
17
|
+
exists: boolean;
|
|
18
|
+
path: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shape of `GET /api/projects/:id/storyboard`. */
|
|
23
|
+
export interface StoryboardResponse {
|
|
24
|
+
exists: boolean;
|
|
25
|
+
path: string;
|
|
26
|
+
globals: StoryboardGlobals;
|
|
27
|
+
frames: StoryboardFrameView[];
|
|
28
|
+
warnings: StoryboardWarning[];
|
|
29
|
+
script?: StoryboardScript;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UseStoryboardResult {
|
|
33
|
+
data: StoryboardResponse | null;
|
|
34
|
+
loading: boolean;
|
|
35
|
+
error: string | null;
|
|
36
|
+
reload: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load the parsed storyboard manifest for a project. Markdown stays canonical on
|
|
41
|
+
* disk; this fetches the server-derived JSON the storyboard view renders.
|
|
42
|
+
*/
|
|
43
|
+
export function useStoryboard(projectId: string | null): UseStoryboardResult {
|
|
44
|
+
const [data, setData] = useState<StoryboardResponse | null>(null);
|
|
45
|
+
const [loading, setLoading] = useState(true);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
48
|
+
|
|
49
|
+
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!projectId) return;
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
setLoading(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
|
|
57
|
+
// Route through buildProjectApiPath so the (URL-derived) projectId is encoded
|
|
58
|
+
// into the path rather than interpolated raw (CodeQL js/client-side-request-forgery).
|
|
59
|
+
fetch(buildProjectApiPath(projectId, "/storyboard"))
|
|
60
|
+
.then((res) => {
|
|
61
|
+
if (!res.ok) throw new Error(`storyboard request failed: ${res.status}`);
|
|
62
|
+
return res.json() as Promise<StoryboardResponse>;
|
|
63
|
+
})
|
|
64
|
+
.then((json) => {
|
|
65
|
+
if (!cancelled) setData(json);
|
|
66
|
+
})
|
|
67
|
+
.catch((err: unknown) => {
|
|
68
|
+
if (!cancelled) setError(err instanceof Error ? err.message : "failed to load storyboard");
|
|
69
|
+
})
|
|
70
|
+
.finally(() => {
|
|
71
|
+
if (!cancelled) setLoading(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
cancelled = true;
|
|
76
|
+
};
|
|
77
|
+
}, [projectId, reloadKey]);
|
|
78
|
+
|
|
79
|
+
return { data, loading, error, reload };
|
|
80
|
+
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
// Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale +
|
|
2
|
-
// playback-start resolution)
|
|
3
|
-
// the move/resize .then() chains, which nudges several callbacks over the CC
|
|
4
|
-
// threshold. The added branches are telemetry-only.
|
|
2
|
+
// playback-start resolution).
|
|
5
3
|
// fallow-ignore-file complexity
|
|
6
4
|
import { useCallback, useRef } from "react";
|
|
7
|
-
import type { Composition } from "@hyperframes/sdk";
|
|
8
|
-
import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow";
|
|
9
5
|
import type { TimelineElement } from "../player";
|
|
10
6
|
import { usePlayerStore } from "../player";
|
|
11
7
|
import { useRazorSplit } from "./useRazorSplit";
|
|
@@ -37,6 +33,8 @@ import {
|
|
|
37
33
|
scaleGsapPositions,
|
|
38
34
|
} from "./timelineEditingHelpers";
|
|
39
35
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
36
|
+
import { sdkTimingPersist } from "../utils/sdkCutover";
|
|
37
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
40
38
|
|
|
41
39
|
// ── Types ──
|
|
42
40
|
|
|
@@ -60,8 +58,10 @@ interface UseTimelineEditingOptions {
|
|
|
60
58
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
61
59
|
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
62
60
|
isRecordingRef?: React.RefObject<boolean>;
|
|
63
|
-
/** Stage 7
|
|
61
|
+
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
|
64
62
|
sdkSession?: Composition | null;
|
|
63
|
+
/** Resync the SDK session after a server-authoritative timeline write. */
|
|
64
|
+
forceReloadSdkSession?: () => void;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// ── Hook ──
|
|
@@ -80,6 +80,7 @@ export function useTimelineEditing({
|
|
|
80
80
|
uploadProjectFiles,
|
|
81
81
|
isRecordingRef,
|
|
82
82
|
sdkSession,
|
|
83
|
+
forceReloadSdkSession,
|
|
83
84
|
}: UseTimelineEditingOptions) {
|
|
84
85
|
const projectIdRef = useRef(projectId);
|
|
85
86
|
projectIdRef.current = projectId;
|
|
@@ -92,6 +93,7 @@ export function useTimelineEditing({
|
|
|
92
93
|
element: TimelineElement,
|
|
93
94
|
label: string,
|
|
94
95
|
buildPatches: PersistTimelineEditInput["buildPatches"],
|
|
96
|
+
coalesceKey?: string,
|
|
95
97
|
): Promise<void> => {
|
|
96
98
|
if (isRecordingRef?.current) {
|
|
97
99
|
showToast("Cannot edit timeline while recording", "error");
|
|
@@ -99,19 +101,25 @@ export function useTimelineEditing({
|
|
|
99
101
|
}
|
|
100
102
|
const pid = projectIdRef.current;
|
|
101
103
|
if (!pid) return Promise.resolve();
|
|
102
|
-
const queued = editQueueRef.current
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
const queued = editQueueRef.current
|
|
105
|
+
.then(() =>
|
|
106
|
+
persistTimelineEdit({
|
|
107
|
+
projectId: pid,
|
|
108
|
+
element,
|
|
109
|
+
activeCompPath,
|
|
110
|
+
label,
|
|
111
|
+
buildPatches,
|
|
112
|
+
writeProjectFile,
|
|
113
|
+
recordEdit,
|
|
114
|
+
domEditSaveTimestampRef,
|
|
115
|
+
pendingTimelineEditPathRef,
|
|
116
|
+
coalesceKey,
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
.then(() => {
|
|
120
|
+
// Server wrote the file; resync the stale in-memory SDK doc.
|
|
121
|
+
forceReloadSdkSession?.();
|
|
122
|
+
});
|
|
115
123
|
editQueueRef.current = queued.catch((error) => {
|
|
116
124
|
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
|
117
125
|
});
|
|
@@ -125,18 +133,20 @@ export function useTimelineEditing({
|
|
|
125
133
|
pendingTimelineEditPathRef,
|
|
126
134
|
showToast,
|
|
127
135
|
isRecordingRef,
|
|
136
|
+
forceReloadSdkSession,
|
|
128
137
|
],
|
|
129
138
|
);
|
|
130
139
|
|
|
140
|
+
// fallow-ignore-next-line complexity
|
|
131
141
|
const handleTimelineElementMove = useCallback(
|
|
142
|
+
// fallow-ignore-next-line complexity
|
|
132
143
|
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
133
144
|
patchIframeDomTiming(previewIframeRef.current, element, [
|
|
134
145
|
["data-start", formatTimelineAttributeNumber(updates.start)],
|
|
135
146
|
["data-track-index", String(updates.track)],
|
|
136
147
|
]);
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
return enqueueEdit(element, "Move timeline clip", (original, target) => {
|
|
148
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
149
|
+
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
|
140
150
|
let patched = applyPatchByTarget(original, target, {
|
|
141
151
|
type: "attribute",
|
|
142
152
|
property: "start",
|
|
@@ -147,24 +157,63 @@ export function useTimelineEditing({
|
|
|
147
157
|
property: "track-index",
|
|
148
158
|
value: String(updates.track),
|
|
149
159
|
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
};
|
|
161
|
+
// Server-path fallback (no SDK session): persist the attr patch, then
|
|
162
|
+
// shift GSAP tween positions on the server and reload the preview — the
|
|
163
|
+
// SDK path folds both into setTiming, but the fallback must do them
|
|
164
|
+
// explicitly or the clip moves while its GSAP tweens stay put + the
|
|
165
|
+
// preview never refreshes. coalesceKey mirrors the SDK branch so undo
|
|
166
|
+
// granularity is identical on either path.
|
|
167
|
+
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
|
168
|
+
const moveFallback = () =>
|
|
169
|
+
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
|
|
170
|
+
const pid = projectIdRef.current;
|
|
171
|
+
const delta = updates.start - element.start;
|
|
172
|
+
if (delta !== 0 && element.domId && pid) {
|
|
173
|
+
return shiftGsapPositions(pid, targetPath, element.domId, delta)
|
|
174
|
+
.then(() => reloadPreview())
|
|
175
|
+
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
|
|
176
|
+
}
|
|
177
|
+
return reloadPreview();
|
|
178
|
+
});
|
|
179
|
+
if (sdkSession && element.hfId) {
|
|
180
|
+
return sdkTimingPersist(
|
|
181
|
+
element.hfId,
|
|
182
|
+
targetPath,
|
|
183
|
+
{ start: updates.start, trackIndex: updates.track },
|
|
184
|
+
sdkSession,
|
|
185
|
+
{
|
|
186
|
+
editHistory: { recordEdit },
|
|
187
|
+
writeProjectFile,
|
|
188
|
+
reloadPreview,
|
|
189
|
+
domEditSaveTimestampRef,
|
|
190
|
+
compositionPath: activeCompPath,
|
|
191
|
+
// Capture on-disk bytes as the undo `before` so undoing a timing move
|
|
192
|
+
// restores the file verbatim, not a normalized full-DOM re-emit.
|
|
193
|
+
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
194
|
+
},
|
|
195
|
+
{ label: "Move timeline clip", coalesceKey },
|
|
196
|
+
).then((handled) => {
|
|
197
|
+
if (!handled) return moveFallback();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return moveFallback();
|
|
163
201
|
},
|
|
164
|
-
[
|
|
202
|
+
[
|
|
203
|
+
previewIframeRef,
|
|
204
|
+
enqueueEdit,
|
|
205
|
+
activeCompPath,
|
|
206
|
+
sdkSession,
|
|
207
|
+
recordEdit,
|
|
208
|
+
writeProjectFile,
|
|
209
|
+
reloadPreview,
|
|
210
|
+
domEditSaveTimestampRef,
|
|
211
|
+
],
|
|
165
212
|
);
|
|
166
213
|
|
|
214
|
+
// fallow-ignore-next-line complexity
|
|
167
215
|
const handleTimelineElementResize = useCallback(
|
|
216
|
+
// fallow-ignore-next-line complexity
|
|
168
217
|
(
|
|
169
218
|
element: TimelineElement,
|
|
170
219
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
@@ -173,6 +222,9 @@ export function useTimelineEditing({
|
|
|
173
222
|
["data-start", formatTimelineAttributeNumber(updates.start)],
|
|
174
223
|
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
|
175
224
|
];
|
|
225
|
+
// Patch the live playback-start/media-start attr too, or a resize that
|
|
226
|
+
// trims the playback start leaves the preview showing the old in-point
|
|
227
|
+
// until the next reload (the persisted patch handles it via pbs below).
|
|
176
228
|
if (updates.playbackStart != null) {
|
|
177
229
|
const liveAttr =
|
|
178
230
|
element.playbackStartAttr === "playback-start"
|
|
@@ -181,10 +233,8 @@ export function useTimelineEditing({
|
|
|
181
233
|
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
|
|
182
234
|
}
|
|
183
235
|
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
updates.start !== element.start || updates.duration !== element.duration;
|
|
187
|
-
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
|
|
236
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
237
|
+
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
|
188
238
|
const pbs = resolveResizePlaybackStart(original, target, element, updates);
|
|
189
239
|
let patched = applyPatchByTarget(original, target, {
|
|
190
240
|
type: "attribute",
|
|
@@ -204,34 +254,77 @@ export function useTimelineEditing({
|
|
|
204
254
|
});
|
|
205
255
|
}
|
|
206
256
|
return patched;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
257
|
+
};
|
|
258
|
+
// SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
|
|
259
|
+
// The second clause fires because trimming the start of a clip that has a
|
|
260
|
+
// playback-start attribute implicitly shifts that in-point — which the SDK
|
|
261
|
+
// setTiming op can't express — so those resizes must take the server path.
|
|
262
|
+
const hasPbsAdjustment =
|
|
263
|
+
updates.playbackStart != null ||
|
|
264
|
+
(updates.start !== element.start && element.playbackStart != null);
|
|
265
|
+
// Server-path fallback: after persisting the attr patch, scale GSAP tween
|
|
266
|
+
// positions/durations on the server and reload the preview. The SDK path
|
|
267
|
+
// folds both into setTiming; the fallback must do them explicitly or the
|
|
268
|
+
// clip resizes while its GSAP tweens keep their old timing + the preview
|
|
269
|
+
// never refreshes. coalesceKey mirrors the SDK branch for undo parity.
|
|
270
|
+
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
|
|
271
|
+
const timingChanged =
|
|
272
|
+
updates.start !== element.start || updates.duration !== element.duration;
|
|
273
|
+
const resizeFallback = () =>
|
|
274
|
+
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
|
|
275
|
+
const pid = projectIdRef.current;
|
|
276
|
+
if (timingChanged && element.domId && pid) {
|
|
277
|
+
return scaleGsapPositions(
|
|
278
|
+
pid,
|
|
279
|
+
targetPath,
|
|
280
|
+
element.domId,
|
|
281
|
+
element.start,
|
|
282
|
+
element.duration,
|
|
283
|
+
updates.start,
|
|
284
|
+
updates.duration,
|
|
285
|
+
)
|
|
286
|
+
.then(() => reloadPreview())
|
|
287
|
+
.catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
|
|
288
|
+
}
|
|
289
|
+
return reloadPreview();
|
|
290
|
+
});
|
|
291
|
+
if (sdkSession && element.hfId && !hasPbsAdjustment) {
|
|
292
|
+
return sdkTimingPersist(
|
|
293
|
+
element.hfId,
|
|
294
|
+
targetPath,
|
|
295
|
+
{ start: updates.start, duration: updates.duration },
|
|
296
|
+
sdkSession,
|
|
297
|
+
{
|
|
298
|
+
editHistory: { recordEdit },
|
|
299
|
+
writeProjectFile,
|
|
300
|
+
reloadPreview,
|
|
301
|
+
domEditSaveTimestampRef,
|
|
302
|
+
compositionPath: activeCompPath,
|
|
303
|
+
// Capture on-disk bytes as the undo `before` so undoing a timing
|
|
304
|
+
// resize restores the file verbatim, not a normalized full-DOM re-emit.
|
|
305
|
+
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
306
|
+
},
|
|
307
|
+
{ label: "Resize timeline clip", coalesceKey },
|
|
308
|
+
).then((handled) => {
|
|
309
|
+
if (!handled) return resizeFallback();
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return resizeFallback();
|
|
229
313
|
},
|
|
230
|
-
[
|
|
314
|
+
[
|
|
315
|
+
previewIframeRef,
|
|
316
|
+
enqueueEdit,
|
|
317
|
+
activeCompPath,
|
|
318
|
+
sdkSession,
|
|
319
|
+
recordEdit,
|
|
320
|
+
writeProjectFile,
|
|
321
|
+
reloadPreview,
|
|
322
|
+
domEditSaveTimestampRef,
|
|
323
|
+
],
|
|
231
324
|
);
|
|
232
325
|
|
|
326
|
+
// fallow-ignore-next-line complexity
|
|
233
327
|
const handleTimelineElementDelete = useCallback(
|
|
234
|
-
// Pre-existing handler complexity, unchanged by this PR.
|
|
235
328
|
// fallow-ignore-next-line complexity
|
|
236
329
|
async (element: TimelineElement) => {
|
|
237
330
|
if (isRecordingRef?.current) {
|
|
@@ -287,8 +380,8 @@ export function useTimelineEditing({
|
|
|
287
380
|
timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
|
|
288
381
|
);
|
|
289
382
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
383
|
+
forceReloadSdkSession?.();
|
|
290
384
|
reloadPreview();
|
|
291
|
-
if (sdkSession) runShadowDelete(sdkSession, element.hfId);
|
|
292
385
|
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
|
293
386
|
} catch (error) {
|
|
294
387
|
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
@@ -304,12 +397,12 @@ export function useTimelineEditing({
|
|
|
304
397
|
domEditSaveTimestampRef,
|
|
305
398
|
reloadPreview,
|
|
306
399
|
isRecordingRef,
|
|
307
|
-
|
|
400
|
+
forceReloadSdkSession,
|
|
308
401
|
],
|
|
309
402
|
);
|
|
310
403
|
|
|
404
|
+
// fallow-ignore-next-line complexity
|
|
311
405
|
const handleTimelineAssetDrop = useCallback(
|
|
312
|
-
// Pre-existing handler complexity, unchanged by this PR.
|
|
313
406
|
// fallow-ignore-next-line complexity
|
|
314
407
|
async (
|
|
315
408
|
assetPath: string,
|
|
@@ -373,6 +466,7 @@ export function useTimelineEditing({
|
|
|
373
466
|
recordEdit,
|
|
374
467
|
});
|
|
375
468
|
|
|
469
|
+
forceReloadSdkSession?.();
|
|
376
470
|
reloadPreview();
|
|
377
471
|
} catch (error) {
|
|
378
472
|
const message =
|
|
@@ -389,11 +483,12 @@ export function useTimelineEditing({
|
|
|
389
483
|
domEditSaveTimestampRef,
|
|
390
484
|
reloadPreview,
|
|
391
485
|
isRecordingRef,
|
|
486
|
+
forceReloadSdkSession,
|
|
392
487
|
],
|
|
393
488
|
);
|
|
394
489
|
|
|
490
|
+
// fallow-ignore-next-line complexity
|
|
395
491
|
const handleTimelineFileDrop = useCallback(
|
|
396
|
-
// Pre-existing handler complexity, unchanged by this PR.
|
|
397
492
|
// fallow-ignore-next-line complexity
|
|
398
493
|
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
399
494
|
if (isRecordingRef?.current) {
|
|
@@ -31,6 +31,19 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
|
|
31
31
|
return results;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Extract the GSAP timeline script text from a serialized HTML document, for
|
|
36
|
+
* feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
|
|
37
|
+
* are present (ambiguous — caller should fall back to a full reload), matching
|
|
38
|
+
* applySoftReload's own single-script requirement.
|
|
39
|
+
*/
|
|
40
|
+
export function extractGsapScriptText(html: string): string | null {
|
|
41
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
42
|
+
const scripts = findGsapScriptElements(doc);
|
|
43
|
+
if (scripts.length !== 1) return null;
|
|
44
|
+
return scripts[0].textContent || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
/** Check that the new script repopulated __timelines with at least one entry. */
|
|
35
48
|
function verifyTimelinesPopulated(win: IframeWindow): boolean {
|
|
36
49
|
const tlKeys = win.__timelines
|
|
@@ -73,6 +86,7 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|
|
73
86
|
// full iframe reload that destroys the very WebGL context we're preserving.
|
|
74
87
|
let deferredToAsync = false;
|
|
75
88
|
|
|
89
|
+
// fallow-ignore-next-line complexity
|
|
76
90
|
const doReload = () => {
|
|
77
91
|
const timelines = win.__timelines;
|
|
78
92
|
const allTargets: Element[] = [];
|