@hyperframes/studio 0.6.100 → 0.6.101
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-BITwbxi-.css +1 -0
- package/dist/assets/{index-gpSohHUn.js → index-CQ3n6Y9q.js} +1 -1
- package/dist/assets/{index-BkT9VKwz.js → index-CTiqZ7XQ.js} +165 -165
- package/dist/assets/{index-CKWBqyRd.js → index-DvttAtOD.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/App.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +24 -16
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/nle/NLELayout.tsx +89 -1
- package/src/hooks/gsapScriptCommitHelpers.ts +8 -5
- package/src/hooks/gsapScriptCommitTypes.ts +3 -0
- package/src/hooks/gsapTargetCache.ts +65 -0
- package/src/hooks/useAppHotkeys.ts +10 -0
- package/src/hooks/useDomEditCommits.ts +6 -5
- package/src/hooks/useDomEditSession.ts +6 -1
- package/src/hooks/useDomGeometryCommits.ts +1 -36
- package/src/hooks/useElementLifecycleOps.ts +5 -0
- package/src/hooks/useGsapAnimationOps.ts +26 -1
- package/src/hooks/useGsapScriptCommits.ts +5 -2
- package/src/hooks/useRazorSplit.ts +3 -0
- package/src/hooks/useSdkSession.ts +15 -12
- package/src/hooks/useTimelineEditing.ts +23 -3
- package/src/player/components/Timeline.tsx +31 -18
- package/src/player/components/TimelineClip.tsx +3 -3
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +30 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +91 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +153 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +22 -0
- package/src/player/store/playerStore.ts +22 -8
- package/src/telemetry/events.test.ts +16 -1
- package/src/telemetry/events.ts +15 -0
- package/src/utils/blockCategories.ts +2 -2
- package/src/utils/sdkShadow.test.ts +102 -2
- package/src/utils/sdkShadow.ts +200 -2
- package/src/utils/studioHelpers.test.ts +25 -1
- package/src/utils/studioHelpers.ts +54 -28
- package/dist/assets/index-B62bDCQv.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
2
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
3
3
|
import type { BeatEditState } from "../../utils/beatEditing";
|
|
4
|
+
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
4
5
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
5
6
|
|
|
6
7
|
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
|
@@ -50,6 +51,13 @@ export interface TimelineElement {
|
|
|
50
51
|
timelineLocked?: boolean;
|
|
51
52
|
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
|
|
52
53
|
timelineRole?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
|
56
|
+
* child: the absolute master-timeline start of the sub-comp host the child
|
|
57
|
+
* lives in. Presence marks the element as expanded; edits subtract it to get
|
|
58
|
+
* the child's local (sourceFile-relative) time. Works at any nesting depth.
|
|
59
|
+
*/
|
|
60
|
+
expandedParentStart?: number;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
export type ZoomMode = "fit" | "manual";
|
|
@@ -138,21 +146,22 @@ interface PlayerState {
|
|
|
138
146
|
/** Undo/redo stacks for beat edits (in-memory, session-only). */
|
|
139
147
|
beatUndo: BeatHistoryEntry[];
|
|
140
148
|
beatRedo: BeatHistoryEntry[];
|
|
141
|
-
/** Apply a beat edit and record it for undo. */
|
|
142
149
|
commitBeatEdits: (next: BeatEditState | null, label: string) => void;
|
|
143
|
-
/** Undo/redo the most recent beat edit; returns its label or null if none. */
|
|
144
150
|
undoBeatEdits: () => string | null;
|
|
145
151
|
redoBeatEdits: () => string | null;
|
|
146
|
-
/** Clear beat edit history (e.g. when the music track changes). */
|
|
147
152
|
resetBeatHistory: () => void;
|
|
148
|
-
/** Callback that persists current beats to disk; registered by the analysis hook. */
|
|
149
153
|
beatPersist: (() => void) | null;
|
|
150
154
|
setBeatPersist: (fn: (() => void) | null) => void;
|
|
155
|
+
|
|
156
|
+
clipManifest: ClipManifestClip[] | null;
|
|
157
|
+
setClipManifest: (clips: ClipManifestClip[] | null) => void;
|
|
158
|
+
clipParentMap: Map<string, string>;
|
|
159
|
+
setClipParentMap: (map: Map<string, string>) => void;
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
interface BeatHistoryEntry {
|
|
154
|
-
restore: BeatEditState | null;
|
|
155
|
-
at: number;
|
|
163
|
+
restore: BeatEditState | null;
|
|
164
|
+
at: number;
|
|
156
165
|
label: string;
|
|
157
166
|
}
|
|
158
167
|
|
|
@@ -271,6 +280,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
271
280
|
return entry.label;
|
|
272
281
|
},
|
|
273
282
|
|
|
283
|
+
clipManifest: null,
|
|
284
|
+
setClipManifest: (clips) => set({ clipManifest: clips }),
|
|
285
|
+
clipParentMap: new Map(),
|
|
286
|
+
setClipParentMap: (map) => set({ clipParentMap: map }),
|
|
287
|
+
|
|
274
288
|
setIsPlaying: (playing) => {
|
|
275
289
|
if (get().isPlaying === playing) return;
|
|
276
290
|
set({ isPlaying: playing });
|
|
@@ -338,12 +352,12 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
338
352
|
selectedKeyframes: new Set(),
|
|
339
353
|
selectedElementIds: new Set(),
|
|
340
354
|
keyframeCache: new Map(),
|
|
341
|
-
// Beat state is project-specific — clear it so a project switch can't
|
|
342
|
-
// apply the previous project's beats/undo/persist to the new one.
|
|
343
355
|
beatAnalysis: null,
|
|
344
356
|
beatEdits: null,
|
|
345
357
|
beatUndo: [],
|
|
346
358
|
beatRedo: [],
|
|
347
359
|
beatPersist: null,
|
|
360
|
+
clipManifest: null,
|
|
361
|
+
clipParentMap: new Map(),
|
|
348
362
|
}),
|
|
349
363
|
}));
|
|
@@ -7,7 +7,12 @@ vi.mock("./client", () => ({
|
|
|
7
7
|
trackEvent: (...args: unknown[]) => trackEvent(...args),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
trackStudioSessionStart,
|
|
12
|
+
trackStudioRenderStart,
|
|
13
|
+
trackStudioRazorSplit,
|
|
14
|
+
trackStudioExpandedClipEdit,
|
|
15
|
+
} = await import("./events");
|
|
11
16
|
|
|
12
17
|
describe("studio telemetry events", () => {
|
|
13
18
|
beforeEach(() => {
|
|
@@ -54,4 +59,14 @@ describe("studio telemetry events", () => {
|
|
|
54
59
|
composition: undefined,
|
|
55
60
|
});
|
|
56
61
|
});
|
|
62
|
+
|
|
63
|
+
it("trackStudioRazorSplit emits 'studio_razor_split' with mode and count", () => {
|
|
64
|
+
trackStudioRazorSplit({ mode: "all", count: 3 });
|
|
65
|
+
expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("trackStudioExpandedClipEdit emits 'studio_expanded_clip_edit' with action", () => {
|
|
69
|
+
trackStudioExpandedClipEdit({ action: "resize" });
|
|
70
|
+
expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
|
|
71
|
+
});
|
|
57
72
|
});
|
package/src/telemetry/events.ts
CHANGED
|
@@ -48,6 +48,21 @@ function getBrowserDoctorSummary(): string {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export function trackStudioRazorSplit(props: { mode: "single" | "all"; count: number }): void {
|
|
52
|
+
trackEvent("studio_razor_split", {
|
|
53
|
+
mode: props.mode,
|
|
54
|
+
count: props.count,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Adoption signal for the inline timeline-expansion surface: edits applied to a
|
|
59
|
+
// sub-composition child clip while its parent scene is expanded.
|
|
60
|
+
export function trackStudioExpandedClipEdit(props: {
|
|
61
|
+
action: "move" | "resize" | "delete" | "split";
|
|
62
|
+
}): void {
|
|
63
|
+
trackEvent("studio_expanded_clip_edit", { action: props.action });
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
|
|
52
67
|
trackEvent("survey sent", {
|
|
53
68
|
$survey_id: "studio_experience",
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type BlockCategory,
|
|
3
|
-
type BlockCategoryMeta,
|
|
4
3
|
BLOCK_CATEGORIES,
|
|
5
4
|
resolveBlockCategory,
|
|
6
5
|
} from "@hyperframes/core/registry";
|
|
7
6
|
|
|
8
|
-
export type { BlockCategory
|
|
7
|
+
export type { BlockCategory };
|
|
9
8
|
export { BLOCK_CATEGORIES, resolveBlockCategory };
|
|
10
9
|
|
|
11
10
|
const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }> = {
|
|
@@ -17,6 +16,7 @@ const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }
|
|
|
17
16
|
captions: { bg: "bg-cyan-500/15", text: "text-cyan-400", dot: "bg-cyan-400" },
|
|
18
17
|
effects: { bg: "bg-rose-500/15", text: "text-rose-400", dot: "bg-rose-400" },
|
|
19
18
|
"text-effects": { bg: "bg-violet-500/15", text: "text-violet-400", dot: "bg-violet-400" },
|
|
19
|
+
"code-animation": { bg: "bg-emerald-500/15", text: "text-emerald-400", dot: "bg-emerald-400" },
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export function getCategoryColors(category: BlockCategory) {
|
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
patchOpsToSdkEditOps,
|
|
4
|
+
runShadowDelete,
|
|
5
|
+
runShadowTiming,
|
|
6
|
+
runShadowGsapTween,
|
|
7
|
+
SdkShadowMismatch,
|
|
8
|
+
} from "./sdkShadow";
|
|
3
9
|
import type { PatchOperation } from "./sourcePatcher";
|
|
4
10
|
import { openComposition } from "@hyperframes/sdk";
|
|
5
11
|
|
|
12
|
+
// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
|
|
13
|
+
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
|
14
|
+
vi.mock("./studioTelemetry", () => ({
|
|
15
|
+
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
|
16
|
+
trackedEvents.push({ event, props }),
|
|
17
|
+
}));
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
trackedEvents.length = 0;
|
|
20
|
+
});
|
|
21
|
+
const lastShadow = () =>
|
|
22
|
+
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
|
|
23
|
+
|
|
6
24
|
const BASE_HTML = /* html */ `<!DOCTYPE html>
|
|
7
25
|
<html><body>
|
|
8
26
|
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
|
|
@@ -144,3 +162,85 @@ describe("sdkShadowDispatch (integration)", () => {
|
|
|
144
162
|
});
|
|
145
163
|
});
|
|
146
164
|
});
|
|
165
|
+
|
|
166
|
+
const TIMING_HTML = /* html */ `<!DOCTYPE html>
|
|
167
|
+
<html><body>
|
|
168
|
+
<div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
|
|
169
|
+
</body></html>`;
|
|
170
|
+
|
|
171
|
+
const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
|
172
|
+
<div data-hf-id="hf-box" style="opacity:0"></div>
|
|
173
|
+
<script>var tl = gsap.timeline({ paused: true });
|
|
174
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
|
175
|
+
window.__timelines["t"] = tl;</script>
|
|
176
|
+
</div>`;
|
|
177
|
+
|
|
178
|
+
const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
|
|
179
|
+
<div data-hf-id="hf-box"></div>
|
|
180
|
+
<script>gsap.defaults({ ease: "power1.out" });
|
|
181
|
+
window.__timelines = {};</script>
|
|
182
|
+
</div>`;
|
|
183
|
+
|
|
184
|
+
describe("runShadowDelete", () => {
|
|
185
|
+
it("removes the element from the SDK session and reports parity", async () => {
|
|
186
|
+
const session = await openComposition(BASE_HTML);
|
|
187
|
+
runShadowDelete(session, "hf-box");
|
|
188
|
+
expect(session.getElement("hf-box")).toBeNull();
|
|
189
|
+
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("reports no_hf_id when selection has no hf-id", async () => {
|
|
193
|
+
const session = await openComposition(BASE_HTML);
|
|
194
|
+
runShadowDelete(session, null);
|
|
195
|
+
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("reports cannot_dispatch when the element is not addressable", async () => {
|
|
199
|
+
const session = await openComposition(BASE_HTML);
|
|
200
|
+
runShadowDelete(session, "hf-missing");
|
|
201
|
+
expect(lastShadow()).toMatchObject({
|
|
202
|
+
op: "delete",
|
|
203
|
+
dispatched: false,
|
|
204
|
+
reason: "cannot_dispatch",
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("runShadowTiming", () => {
|
|
210
|
+
it("applies timing and reports parity against the snapshot", async () => {
|
|
211
|
+
const session = await openComposition(TIMING_HTML);
|
|
212
|
+
runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 });
|
|
213
|
+
const el = session.getElement("hf-clip");
|
|
214
|
+
expect(el?.start).toBe(2);
|
|
215
|
+
expect(el?.duration).toBe(3);
|
|
216
|
+
expect(el?.trackIndex).toBe(1);
|
|
217
|
+
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("runShadowGsapTween", () => {
|
|
222
|
+
it("dispatches add against a real timeline and reports success", async () => {
|
|
223
|
+
const session = await openComposition(GSAP_HTML);
|
|
224
|
+
runShadowGsapTween(session, {
|
|
225
|
+
kind: "add",
|
|
226
|
+
target: "hf-box",
|
|
227
|
+
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
|
228
|
+
});
|
|
229
|
+
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => {
|
|
233
|
+
const session = await openComposition(NO_TIMELINE_HTML);
|
|
234
|
+
runShadowGsapTween(session, {
|
|
235
|
+
kind: "add",
|
|
236
|
+
target: "hf-box",
|
|
237
|
+
tween: { method: "to", properties: { x: 100 } },
|
|
238
|
+
});
|
|
239
|
+
expect(lastShadow()).toMatchObject({
|
|
240
|
+
op: "gsap",
|
|
241
|
+
dispatched: false,
|
|
242
|
+
reason: "cannot_dispatch",
|
|
243
|
+
code: "E_NO_GSAP_TIMELINE",
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
package/src/utils/sdkShadow.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Composition } from "@hyperframes/sdk";
|
|
12
|
-
import type { EditOp } from "@hyperframes/sdk";
|
|
12
|
+
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
|
|
13
13
|
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
14
14
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
15
15
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
@@ -182,6 +182,26 @@ export function sdkShadowDispatch(
|
|
|
182
182
|
* Despite the telemetry focus, this function does mutate the SDK session — it
|
|
183
183
|
* is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
|
184
184
|
*/
|
|
185
|
+
// Property-path mismatches carry user content (inline-style values, edited
|
|
186
|
+
// text) in expected/actual. Scrub before telemetry: fully redact text-content
|
|
187
|
+
// values, length-cap the rest. The in-memory parity result keeps raw values.
|
|
188
|
+
function redactValueForTelemetry(
|
|
189
|
+
property: string | undefined,
|
|
190
|
+
value: string | null | undefined,
|
|
191
|
+
): string | null | undefined {
|
|
192
|
+
if (value == null) return value;
|
|
193
|
+
if (property === "text") return `[redacted len=${value.length}]`;
|
|
194
|
+
return value.length > 64 ? `${value.slice(0, 64)}…` : value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] {
|
|
198
|
+
return mismatches.map((m) => ({
|
|
199
|
+
...m,
|
|
200
|
+
expected: redactValueForTelemetry(m.property, m.expected),
|
|
201
|
+
actual: redactValueForTelemetry(m.property, m.actual),
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
export function runShadowDispatch(
|
|
186
206
|
session: Composition,
|
|
187
207
|
selection: DomEditSelection,
|
|
@@ -191,6 +211,7 @@ export function runShadowDispatch(
|
|
|
191
211
|
const hfId = selection.hfId;
|
|
192
212
|
if (!hfId) {
|
|
193
213
|
trackStudioEvent("sdk_shadow_dispatch", {
|
|
214
|
+
op: "property",
|
|
194
215
|
dispatched: false,
|
|
195
216
|
reason: "no_hf_id",
|
|
196
217
|
mismatchCount: 0,
|
|
@@ -199,8 +220,185 @@ export function runShadowDispatch(
|
|
|
199
220
|
}
|
|
200
221
|
const result = sdkShadowDispatch(session, hfId, ops);
|
|
201
222
|
trackStudioEvent("sdk_shadow_dispatch", {
|
|
223
|
+
op: "property",
|
|
202
224
|
dispatched: result.dispatched,
|
|
203
225
|
mismatchCount: result.mismatches.length,
|
|
204
|
-
mismatches: JSON.stringify(result.mismatches),
|
|
226
|
+
mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ───────────────
|
|
231
|
+
//
|
|
232
|
+
// These ops never flow through persistDomEditOperations, so the property-path
|
|
233
|
+
// shadow above never sees them. Each runner keeps the server authoritative and
|
|
234
|
+
// only observes the SDK: can() pre-checks addressing/validity (pure, no
|
|
235
|
+
// mutation — works even for GSAP, which has no element-snapshot value), then a
|
|
236
|
+
// dispatch into the live session with a snapshot-based parity check.
|
|
237
|
+
//
|
|
238
|
+
// Parity coverage by op:
|
|
239
|
+
// delete → getElement(id) === null (full)
|
|
240
|
+
// timing → snapshot.start/duration/trackIndex (full)
|
|
241
|
+
// gsap → tween id present/absent in animationIds (existence only — the
|
|
242
|
+
// tween's property values are script-level, not in the snapshot)
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`.
|
|
246
|
+
* Mutates the SDK session (not read-only); server stays authoritative.
|
|
247
|
+
* No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
|
248
|
+
*/
|
|
249
|
+
function runShadowEditOp(
|
|
250
|
+
session: Composition,
|
|
251
|
+
op: EditOp,
|
|
252
|
+
opLabel: string,
|
|
253
|
+
dispatchAndCheck: () => SdkShadowMismatch[],
|
|
254
|
+
): void {
|
|
255
|
+
const verdict = session.can(op);
|
|
256
|
+
if (!verdict.ok) {
|
|
257
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
258
|
+
op: opLabel,
|
|
259
|
+
dispatched: false,
|
|
260
|
+
reason: "cannot_dispatch",
|
|
261
|
+
code: verdict.code,
|
|
262
|
+
mismatchCount: 0,
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
let mismatches: SdkShadowMismatch[];
|
|
267
|
+
try {
|
|
268
|
+
mismatches = dispatchAndCheck();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
271
|
+
op: opLabel,
|
|
272
|
+
dispatched: false,
|
|
273
|
+
reason: "dispatch_error",
|
|
274
|
+
error: String(err),
|
|
275
|
+
mismatchCount: 0,
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
280
|
+
op: opLabel,
|
|
281
|
+
dispatched: true,
|
|
282
|
+
mismatchCount: mismatches.length,
|
|
283
|
+
mismatches: JSON.stringify(mismatches),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Shadow an element delete. Parity: the element is gone from the SDK session. */
|
|
288
|
+
export function runShadowDelete(session: Composition, hfId: string | null | undefined): void {
|
|
289
|
+
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
290
|
+
if (!hfId) {
|
|
291
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
292
|
+
op: "delete",
|
|
293
|
+
dispatched: false,
|
|
294
|
+
reason: "no_hf_id",
|
|
295
|
+
mismatchCount: 0,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const op: EditOp = { type: "removeElement", target: hfId };
|
|
300
|
+
runShadowEditOp(session, op, "delete", () => {
|
|
301
|
+
session.batch(() => session.dispatch(op));
|
|
302
|
+
return session.getElement(hfId)
|
|
303
|
+
? [
|
|
304
|
+
{
|
|
305
|
+
kind: "value_mismatch",
|
|
306
|
+
hfId,
|
|
307
|
+
property: "exists",
|
|
308
|
+
expected: "removed",
|
|
309
|
+
actual: "present",
|
|
310
|
+
},
|
|
311
|
+
]
|
|
312
|
+
: [];
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface ShadowTiming {
|
|
317
|
+
start?: number;
|
|
318
|
+
duration?: number;
|
|
319
|
+
trackIndex?: number;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
|
|
323
|
+
export function runShadowTiming(
|
|
324
|
+
session: Composition,
|
|
325
|
+
hfId: string | null | undefined,
|
|
326
|
+
timing: ShadowTiming,
|
|
327
|
+
): void {
|
|
328
|
+
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
329
|
+
if (!hfId) {
|
|
330
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
331
|
+
op: "timing",
|
|
332
|
+
dispatched: false,
|
|
333
|
+
reason: "no_hf_id",
|
|
334
|
+
mismatchCount: 0,
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const op: EditOp = { type: "setTiming", target: hfId, ...timing };
|
|
339
|
+
runShadowEditOp(session, op, "timing", () => {
|
|
340
|
+
session.batch(() => session.dispatch(op));
|
|
341
|
+
const el = session.getElement(hfId);
|
|
342
|
+
const mismatches: SdkShadowMismatch[] = [];
|
|
343
|
+
const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [
|
|
344
|
+
["start", el?.start],
|
|
345
|
+
["duration", el?.duration],
|
|
346
|
+
["trackIndex", el?.trackIndex],
|
|
347
|
+
];
|
|
348
|
+
for (const [key, actual] of fields) {
|
|
349
|
+
const expected = timing[key];
|
|
350
|
+
if (expected !== undefined && actual !== expected) {
|
|
351
|
+
mismatches.push({
|
|
352
|
+
kind: "value_mismatch",
|
|
353
|
+
hfId,
|
|
354
|
+
property: key,
|
|
355
|
+
expected: String(expected),
|
|
356
|
+
actual: actual == null ? null : String(actual),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return mismatches;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type ShadowGsapOp =
|
|
365
|
+
| { kind: "add"; target: string; tween: GsapTweenSpec }
|
|
366
|
+
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
|
367
|
+
| { kind: "remove"; animationId: string };
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Shadow a GSAP tween mutation. Snapshot value-parity is NOT available: the
|
|
371
|
+
* tween lives in the GSAP <script>, and ElementSnapshot.animationIds is a stub
|
|
372
|
+
* (always [] — see sdk document.ts). So the signal here is can() addressing /
|
|
373
|
+
* validity + dispatch-didn't-throw, plus (for add) that the SDK returned a
|
|
374
|
+
* non-empty tween id. Full fidelity needs serialize()-script round-trip diffing,
|
|
375
|
+
* out of scope for shadow. // ponytail: upgrade when animationIds is populated.
|
|
376
|
+
*/
|
|
377
|
+
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
|
|
378
|
+
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
379
|
+
const op: EditOp =
|
|
380
|
+
gsapOp.kind === "add"
|
|
381
|
+
? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween }
|
|
382
|
+
: gsapOp.kind === "set"
|
|
383
|
+
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
|
|
384
|
+
: { type: "removeGsapTween", animationId: gsapOp.animationId };
|
|
385
|
+
runShadowEditOp(session, op, "gsap", () => {
|
|
386
|
+
let newId: string | undefined;
|
|
387
|
+
session.batch(() => {
|
|
388
|
+
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
|
|
389
|
+
else session.dispatch(op);
|
|
390
|
+
});
|
|
391
|
+
if (gsapOp.kind === "add" && !newId) {
|
|
392
|
+
return [
|
|
393
|
+
{
|
|
394
|
+
kind: "value_mismatch",
|
|
395
|
+
hfId: gsapOp.target,
|
|
396
|
+
property: "tweenId",
|
|
397
|
+
expected: "non-empty",
|
|
398
|
+
actual: null,
|
|
399
|
+
},
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
return [];
|
|
205
403
|
});
|
|
206
404
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveTimelineSelectionSeekTime } from "./studioHelpers";
|
|
2
|
+
import { findMatchingTimelineElementId, resolveTimelineSelectionSeekTime } from "./studioHelpers";
|
|
3
3
|
|
|
4
4
|
describe("resolveTimelineSelectionSeekTime", () => {
|
|
5
5
|
it("keeps the current time when it is already inside the clip range", () => {
|
|
@@ -18,3 +18,27 @@ describe("resolveTimelineSelectionSeekTime", () => {
|
|
|
18
18
|
expect(resolveTimelineSelectionSeekTime(Number.NaN, { start: 2, duration: 5 })).toBe(2);
|
|
19
19
|
});
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
describe("findMatchingTimelineElementId", () => {
|
|
23
|
+
const el = (over: Record<string, unknown>) =>
|
|
24
|
+
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
|
|
25
|
+
|
|
26
|
+
it("matches a top-level element by domId + sourceFile", () => {
|
|
27
|
+
const els = [el({ id: "s1", domId: "s1", sourceFile: "index.html" })];
|
|
28
|
+
expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
|
|
32
|
+
const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
|
|
33
|
+
expect(
|
|
34
|
+
findMatchingTimelineElementId(
|
|
35
|
+
{ id: "stat-3", sourceFile: "compositions/stats-panel.html" },
|
|
36
|
+
els,
|
|
37
|
+
),
|
|
38
|
+
).toBe("compositions/stats-panel.html#stat-3");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null for an unmatched element in index.html", () => {
|
|
42
|
+
expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -116,38 +116,64 @@ export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
|
116
116
|
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
type ElementMatchSelection = Pick<
|
|
120
|
+
DomEditSelection,
|
|
121
|
+
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
122
|
+
>;
|
|
123
|
+
|
|
124
|
+
function matchesByDomId(
|
|
125
|
+
selection: ElementMatchSelection,
|
|
126
|
+
element: TimelineElement,
|
|
127
|
+
selectionSourceFile: string,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (!selection.id) return false;
|
|
130
|
+
return (
|
|
131
|
+
element.domId === selection.id && (element.sourceFile || "index.html") === selectionSourceFile
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function matchesByCompositionHost(
|
|
136
|
+
selection: ElementMatchSelection,
|
|
137
|
+
element: TimelineElement,
|
|
138
|
+
): boolean {
|
|
139
|
+
if (!selection.isCompositionHost || !selection.compositionSrc) return false;
|
|
140
|
+
return element.compositionSrc === selection.compositionSrc;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function matchesBySelector(selection: ElementMatchSelection, element: TimelineElement): boolean {
|
|
144
|
+
if (!selection.selector) return false;
|
|
145
|
+
return (
|
|
146
|
+
element.selector === selection.selector &&
|
|
147
|
+
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
148
|
+
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function elementMatchesSelection(
|
|
153
|
+
selection: ElementMatchSelection,
|
|
154
|
+
element: TimelineElement,
|
|
155
|
+
selectionSourceFile: string,
|
|
156
|
+
): boolean {
|
|
157
|
+
return (
|
|
158
|
+
matchesByDomId(selection, element, selectionSourceFile) ||
|
|
159
|
+
matchesByCompositionHost(selection, element) ||
|
|
160
|
+
matchesBySelector(selection, element)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
119
164
|
export function findMatchingTimelineElementId(
|
|
120
|
-
selection:
|
|
121
|
-
DomEditSelection,
|
|
122
|
-
"id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
|
|
123
|
-
>,
|
|
165
|
+
selection: ElementMatchSelection,
|
|
124
166
|
elements: TimelineElement[],
|
|
125
167
|
): string | null {
|
|
126
168
|
const selectionSourceFile = selection.sourceFile || "index.html";
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
if (
|
|
137
|
-
selection.isCompositionHost &&
|
|
138
|
-
selection.compositionSrc &&
|
|
139
|
-
element.compositionSrc === selection.compositionSrc
|
|
140
|
-
) {
|
|
141
|
-
return element.key ?? element.id;
|
|
142
|
-
}
|
|
143
|
-
if (
|
|
144
|
-
selection.selector &&
|
|
145
|
-
element.selector === selection.selector &&
|
|
146
|
-
(element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
|
|
147
|
-
(element.sourceFile ?? "index.html") === selection.sourceFile
|
|
148
|
-
) {
|
|
149
|
-
return element.key ?? element.id;
|
|
150
|
-
}
|
|
169
|
+
const match = elements.find((el) => elementMatchesSelection(selection, el, selectionSourceFile));
|
|
170
|
+
if (match) return match.key ?? match.id;
|
|
171
|
+
|
|
172
|
+
// Child inside a sub-composition: return a qualified ID so the expansion
|
|
173
|
+
// hook can resolve the child via clipParentMap even though no timeline
|
|
174
|
+
// element exists for it yet (the expansion creates it on the fly).
|
|
175
|
+
if (selection.id && selectionSourceFile !== "index.html") {
|
|
176
|
+
return `${selectionSourceFile}#${selection.id}`;
|
|
151
177
|
}
|
|
152
178
|
|
|
153
179
|
return null;
|