@hyperframes/studio 0.5.7 → 0.6.0-alpha.10

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.
Files changed (75) hide show
  1. package/dist/assets/index-14zH9lqh.css +1 -0
  2. package/dist/assets/index-B-16fRnH.js +108 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +2965 -186
  6. package/src/components/LintModal.tsx +3 -4
  7. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  8. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  9. package/src/components/editor/MotionPanel.tsx +651 -0
  10. package/src/components/editor/PropertyPanel.test.ts +116 -0
  11. package/src/components/editor/PropertyPanel.tsx +2829 -205
  12. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  13. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  14. package/src/components/editor/colorValue.test.ts +82 -0
  15. package/src/components/editor/colorValue.ts +175 -0
  16. package/src/components/editor/domEditing.test.ts +1120 -0
  17. package/src/components/editor/domEditing.ts +1117 -0
  18. package/src/components/editor/floatingPanel.test.ts +34 -0
  19. package/src/components/editor/floatingPanel.ts +54 -0
  20. package/src/components/editor/fontAssets.ts +32 -0
  21. package/src/components/editor/fontCatalog.ts +126 -0
  22. package/src/components/editor/gradientValue.test.ts +89 -0
  23. package/src/components/editor/gradientValue.ts +445 -0
  24. package/src/components/editor/manualEditingAvailability.test.ts +131 -0
  25. package/src/components/editor/manualEditingAvailability.ts +62 -0
  26. package/src/components/editor/manualEdits.test.ts +945 -0
  27. package/src/components/editor/manualEdits.ts +1409 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  29. package/src/components/editor/manualOffsetDrag.ts +307 -0
  30. package/src/components/editor/studioMotion.test.ts +355 -0
  31. package/src/components/editor/studioMotion.ts +632 -0
  32. package/src/components/nle/NLELayout.test.ts +12 -0
  33. package/src/components/nle/NLELayout.tsx +84 -22
  34. package/src/components/nle/NLEPreview.tsx +56 -5
  35. package/src/components/renders/RenderQueue.tsx +24 -11
  36. package/src/components/sidebar/AssetsTab.tsx +3 -4
  37. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  38. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  39. package/src/components/sidebar/LeftSidebar.tsx +194 -179
  40. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  41. package/src/hooks/usePersistentEditHistory.ts +337 -0
  42. package/src/icons/SystemIcons.tsx +2 -0
  43. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  44. package/src/player/components/CompositionThumbnail.tsx +50 -13
  45. package/src/player/components/EditModal.tsx +5 -20
  46. package/src/player/components/Player.test.ts +58 -0
  47. package/src/player/components/Player.tsx +88 -5
  48. package/src/player/components/PlayerControls.tsx +20 -7
  49. package/src/player/components/Timeline.test.ts +20 -0
  50. package/src/player/components/Timeline.tsx +147 -40
  51. package/src/player/components/TimelineClip.test.ts +92 -0
  52. package/src/player/components/TimelineClip.tsx +241 -7
  53. package/src/player/components/timelineEditing.test.ts +16 -3
  54. package/src/player/components/timelineEditing.ts +10 -3
  55. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  56. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  57. package/src/player/store/playerStore.ts +2 -0
  58. package/src/utils/clipboard.test.ts +89 -0
  59. package/src/utils/clipboard.ts +57 -0
  60. package/src/utils/editHistory.test.ts +244 -0
  61. package/src/utils/editHistory.ts +218 -0
  62. package/src/utils/editHistoryStorage.test.ts +37 -0
  63. package/src/utils/editHistoryStorage.ts +99 -0
  64. package/src/utils/mediaTypes.ts +1 -1
  65. package/src/utils/sourcePatcher.test.ts +128 -1
  66. package/src/utils/sourcePatcher.ts +130 -18
  67. package/src/utils/studioFileHistory.test.ts +156 -0
  68. package/src/utils/studioFileHistory.ts +61 -0
  69. package/src/utils/timelineAssetDrop.test.ts +31 -11
  70. package/src/utils/timelineAssetDrop.ts +22 -2
  71. package/src/utils/timelineDiscovery.ts +1 -1
  72. package/src/utils/timelineInspector.test.ts +79 -0
  73. package/src/utils/timelineInspector.ts +116 -0
  74. package/dist/assets/index-04Mp2wOn.css +0 -1
  75. package/dist/assets/index-Dcw3BoVw.js +0 -93
@@ -0,0 +1,632 @@
1
+ import type { DomEditSelection } from "./domEditing";
2
+
3
+ export const STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";
4
+ export const STUDIO_MOTION_TIMELINE_ID = "studio-motion";
5
+
6
+ const STUDIO_MOTION_ATTR = "data-hf-studio-motion";
7
+ const STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR = "data-hf-studio-motion-original-transform";
8
+ const STUDIO_MOTION_ORIGINAL_OPACITY_ATTR = "data-hf-studio-motion-original-opacity";
9
+ const STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR = "data-hf-studio-motion-original-visibility";
10
+
11
+ export interface StudioMotionTarget {
12
+ sourceFile: string;
13
+ selector?: string;
14
+ selectorIndex?: number;
15
+ id?: string;
16
+ }
17
+
18
+ export interface StudioGsapMotionValues {
19
+ x?: number;
20
+ y?: number;
21
+ scale?: number;
22
+ rotation?: number;
23
+ opacity?: number;
24
+ autoAlpha?: number;
25
+ }
26
+
27
+ export interface StudioGsapCustomEase {
28
+ id: string;
29
+ data: string;
30
+ }
31
+
32
+ export interface StudioCustomEaseControlPoints {
33
+ x1: number;
34
+ y1: number;
35
+ x2: number;
36
+ y2: number;
37
+ }
38
+
39
+ export interface StudioGsapMotion {
40
+ kind: "gsap-motion";
41
+ target: StudioMotionTarget;
42
+ start: number;
43
+ duration: number;
44
+ ease: string;
45
+ customEase?: StudioGsapCustomEase;
46
+ from: StudioGsapMotionValues;
47
+ to: StudioGsapMotionValues;
48
+ updatedAt?: string;
49
+ }
50
+
51
+ export type StudioGsapMotionPreset = "fade-up" | "slide" | "pop";
52
+ export type StudioGsapMotionDirection = "up" | "down" | "left" | "right";
53
+
54
+ export const STUDIO_GSAP_EASE_OPTIONS = [
55
+ "none",
56
+ "power1.in",
57
+ "power1.out",
58
+ "power1.inOut",
59
+ "power2.in",
60
+ "power2.out",
61
+ "power2.inOut",
62
+ "power3.in",
63
+ "power3.out",
64
+ "power3.inOut",
65
+ "power4.in",
66
+ "power4.out",
67
+ "power4.inOut",
68
+ "sine.in",
69
+ "sine.out",
70
+ "sine.inOut",
71
+ "expo.in",
72
+ "expo.out",
73
+ "expo.inOut",
74
+ "circ.in",
75
+ "circ.out",
76
+ "circ.inOut",
77
+ "back.in(1.7)",
78
+ "back.out(1.7)",
79
+ "back.inOut(1.7)",
80
+ "elastic.out(1, 0.45)",
81
+ "bounce.out",
82
+ ] as const;
83
+
84
+ const DEFAULT_CUSTOM_EASE_POINTS: StudioCustomEaseControlPoints = {
85
+ x1: 0.215,
86
+ y1: 0.61,
87
+ x2: 0.355,
88
+ y2: 1,
89
+ };
90
+
91
+ const GSAP_EASE_CONTROL_POINTS: Record<string, StudioCustomEaseControlPoints> = {
92
+ none: { x1: 0, y1: 0, x2: 1, y2: 1 },
93
+ "power1.in": { x1: 0.55, y1: 0.085, x2: 0.68, y2: 0.53 },
94
+ "power1.out": { x1: 0.25, y1: 0.46, x2: 0.45, y2: 0.94 },
95
+ "power1.inOut": { x1: 0.455, y1: 0.03, x2: 0.515, y2: 0.955 },
96
+ "power2.in": { x1: 0.55, y1: 0.055, x2: 0.675, y2: 0.19 },
97
+ "power2.out": { x1: 0.215, y1: 0.61, x2: 0.355, y2: 1 },
98
+ "power2.inOut": { x1: 0.645, y1: 0.045, x2: 0.355, y2: 1 },
99
+ "power3.in": { x1: 0.895, y1: 0.03, x2: 0.685, y2: 0.22 },
100
+ "power3.out": { x1: 0.165, y1: 0.84, x2: 0.44, y2: 1 },
101
+ "power3.inOut": { x1: 0.77, y1: 0, x2: 0.175, y2: 1 },
102
+ "power4.in": { x1: 0.755, y1: 0.05, x2: 0.855, y2: 0.06 },
103
+ "power4.out": { x1: 0.23, y1: 1, x2: 0.32, y2: 1 },
104
+ "power4.inOut": { x1: 0.86, y1: 0, x2: 0.07, y2: 1 },
105
+ "sine.in": { x1: 0.47, y1: 0, x2: 0.745, y2: 0.715 },
106
+ "sine.out": { x1: 0.39, y1: 0.575, x2: 0.565, y2: 1 },
107
+ "sine.inOut": { x1: 0.445, y1: 0.05, x2: 0.55, y2: 0.95 },
108
+ "expo.in": { x1: 0.95, y1: 0.05, x2: 0.795, y2: 0.035 },
109
+ "expo.out": { x1: 0.19, y1: 1, x2: 0.22, y2: 1 },
110
+ "expo.inOut": { x1: 1, y1: 0, x2: 0, y2: 1 },
111
+ "circ.in": { x1: 0.6, y1: 0.04, x2: 0.98, y2: 0.335 },
112
+ "circ.out": { x1: 0.075, y1: 0.82, x2: 0.165, y2: 1 },
113
+ "circ.inOut": { x1: 0.785, y1: 0.135, x2: 0.15, y2: 0.86 },
114
+ "back.in(1.7)": { x1: 0.6, y1: -0.28, x2: 0.735, y2: 0.045 },
115
+ "back.out(1.7)": { x1: 0.175, y1: 0.885, x2: 0.32, y2: 1.275 },
116
+ "back.inOut(1.7)": { x1: 0.68, y1: -0.55, x2: 0.265, y2: 1.55 },
117
+ "elastic.out(1, 0.45)": { x1: 0.16, y1: 1.32, x2: 0.28, y2: 0.86 },
118
+ "bounce.out": { x1: 0.34, y1: 1.56, x2: 0.64, y2: 0.74 },
119
+ };
120
+
121
+ const CUSTOM_EASE_DATA_PATTERN =
122
+ /^M\s*0\s*,\s*0\s*C\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s+1\s*,\s*1\s*$/i;
123
+
124
+ export interface StudioGsapPresetMotionOptions {
125
+ start: number;
126
+ duration: number;
127
+ distance: number;
128
+ ease: string;
129
+ direction?: StudioGsapMotionDirection;
130
+ customEase?: StudioGsapCustomEase;
131
+ }
132
+
133
+ export interface StudioMotionManifest {
134
+ version: 1;
135
+ motions: StudioGsapMotion[];
136
+ }
137
+
138
+ interface StudioGsapTimeline {
139
+ fromTo?: (
140
+ target: HTMLElement,
141
+ from: Record<string, unknown>,
142
+ to: Record<string, unknown>,
143
+ at: number,
144
+ ) => StudioGsapTimeline;
145
+ time?: (time: number) => StudioGsapTimeline;
146
+ totalTime?: (time: number, suppressEvents?: boolean) => StudioGsapTimeline;
147
+ pause?: () => StudioGsapTimeline;
148
+ kill?: () => void;
149
+ duration?: () => number;
150
+ }
151
+
152
+ type StudioMotionWindow = Window & {
153
+ gsap?: {
154
+ timeline?: (vars?: Record<string, unknown>) => StudioGsapTimeline;
155
+ set?: (target: HTMLElement, vars: Record<string, unknown>) => void;
156
+ registerPlugin?: (...plugins: unknown[]) => void;
157
+ };
158
+ CustomEase?: { create?: (id: string, data: string) => void };
159
+ __player?: {
160
+ getTime?: () => number;
161
+ renderSeek?: (time: number) => void;
162
+ seek?: (time: number) => void;
163
+ };
164
+ __timeline?: { time?: () => number };
165
+ __timelines?: Record<string, StudioGsapTimeline | undefined>;
166
+ __hfStudioMotionApply?: () => number;
167
+ __hfStudioMotionWrapped?: boolean;
168
+ };
169
+
170
+ export function emptyStudioMotionManifest(): StudioMotionManifest {
171
+ return { version: 1, motions: [] };
172
+ }
173
+
174
+ function clampPositiveNumber(value: number, fallback: number): number {
175
+ return Number.isFinite(value) && value > 0 ? value : fallback;
176
+ }
177
+
178
+ function clampNonNegativeNumber(value: number, fallback: number): number {
179
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
180
+ }
181
+
182
+ function sanitizeEase(value: string): string {
183
+ return value.trim() || "none";
184
+ }
185
+
186
+ function roundEaseNumber(value: number): number {
187
+ return Math.round(value * 1000) / 1000;
188
+ }
189
+
190
+ function clampRange(value: number, min: number, max: number, fallback: number): number {
191
+ return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
192
+ }
193
+
194
+ export function clampStudioCustomEasePoints(
195
+ points: Partial<StudioCustomEaseControlPoints>,
196
+ ): StudioCustomEaseControlPoints {
197
+ return {
198
+ x1: roundEaseNumber(clampRange(points.x1 ?? DEFAULT_CUSTOM_EASE_POINTS.x1, 0, 1, 0.215)),
199
+ y1: roundEaseNumber(clampRange(points.y1 ?? DEFAULT_CUSTOM_EASE_POINTS.y1, -0.6, 1.6, 0.61)),
200
+ x2: roundEaseNumber(clampRange(points.x2 ?? DEFAULT_CUSTOM_EASE_POINTS.x2, 0, 1, 0.355)),
201
+ y2: roundEaseNumber(clampRange(points.y2 ?? DEFAULT_CUSTOM_EASE_POINTS.y2, -0.6, 1.6, 1)),
202
+ };
203
+ }
204
+
205
+ export function parseStudioCustomEaseData(
206
+ data: string | undefined,
207
+ ): StudioCustomEaseControlPoints | null {
208
+ if (!data) return null;
209
+ const match = data.trim().match(CUSTOM_EASE_DATA_PATTERN);
210
+ if (!match) return null;
211
+ const points = {
212
+ x1: Number.parseFloat(match[1] ?? ""),
213
+ y1: Number.parseFloat(match[2] ?? ""),
214
+ x2: Number.parseFloat(match[3] ?? ""),
215
+ y2: Number.parseFloat(match[4] ?? ""),
216
+ };
217
+ if (!Object.values(points).every(Number.isFinite)) return null;
218
+ return clampStudioCustomEasePoints(points);
219
+ }
220
+
221
+ function formatEaseNumber(value: number): string {
222
+ const rounded = roundEaseNumber(value);
223
+ if (Object.is(rounded, -0)) return "0";
224
+ return `${rounded}`;
225
+ }
226
+
227
+ export function serializeStudioCustomEaseData(points: StudioCustomEaseControlPoints): string {
228
+ const clamped = clampStudioCustomEasePoints(points);
229
+ return `M0,0 C${formatEaseNumber(clamped.x1)},${formatEaseNumber(clamped.y1)} ${formatEaseNumber(clamped.x2)},${formatEaseNumber(clamped.y2)} 1,1`;
230
+ }
231
+
232
+ export function controlPointsForGsapEase(ease: string): StudioCustomEaseControlPoints {
233
+ return GSAP_EASE_CONTROL_POINTS[ease] ?? DEFAULT_CUSTOM_EASE_POINTS;
234
+ }
235
+
236
+ export function buildStudioGsapPresetMotion(
237
+ preset: StudioGsapMotionPreset,
238
+ options: StudioGsapPresetMotionOptions,
239
+ ): Omit<StudioGsapMotion, "kind" | "target" | "updatedAt"> {
240
+ const start = clampNonNegativeNumber(options.start, 0);
241
+ const duration = clampPositiveNumber(options.duration, 0.6);
242
+ const distance = clampPositiveNumber(options.distance, 32);
243
+ const ease = sanitizeEase(options.ease);
244
+ const direction = options.direction ?? "up";
245
+ const base = { start, duration, ease, customEase: options.customEase };
246
+
247
+ if (preset === "pop") {
248
+ return {
249
+ ...base,
250
+ from: { scale: 0.88, autoAlpha: 0 },
251
+ to: { scale: 1, autoAlpha: 1 },
252
+ };
253
+ }
254
+
255
+ if (preset === "slide") {
256
+ const x = direction === "right" ? -distance : direction === "left" ? distance : 0;
257
+ const y = direction === "down" ? -distance : direction === "up" ? distance : 0;
258
+ return {
259
+ ...base,
260
+ from: { x, y, autoAlpha: 0 },
261
+ to: { x: 0, y: 0, autoAlpha: 1 },
262
+ };
263
+ }
264
+
265
+ return {
266
+ ...base,
267
+ from: { y: direction === "down" ? -distance : distance, autoAlpha: 0 },
268
+ to: { y: 0, autoAlpha: 1 },
269
+ };
270
+ }
271
+
272
+ function finiteNumber(value: unknown): number | null {
273
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
274
+ }
275
+
276
+ function parseMotionValues(value: unknown): StudioGsapMotionValues | null {
277
+ if (!value || typeof value !== "object") return null;
278
+ const record = value as Record<string, unknown>;
279
+ const parsed: StudioGsapMotionValues = {};
280
+ for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"] as const) {
281
+ const next = finiteNumber(record[key]);
282
+ if (next != null) parsed[key] = next;
283
+ }
284
+ return Object.keys(parsed).length > 0 ? parsed : null;
285
+ }
286
+
287
+ function parseTarget(value: unknown): StudioMotionTarget | null {
288
+ if (!value || typeof value !== "object") return null;
289
+ const record = value as Record<string, unknown>;
290
+ const sourceFile = typeof record.sourceFile === "string" ? record.sourceFile : "";
291
+ if (!sourceFile) return null;
292
+ const selector = typeof record.selector === "string" ? record.selector : undefined;
293
+ const id = typeof record.id === "string" ? record.id : undefined;
294
+ if (!selector && !id) return null;
295
+ return {
296
+ sourceFile,
297
+ selector,
298
+ selectorIndex: finiteNumber(record.selectorIndex) ?? undefined,
299
+ id,
300
+ };
301
+ }
302
+
303
+ function parseCustomEase(value: unknown): StudioGsapCustomEase | undefined {
304
+ if (!value || typeof value !== "object") return undefined;
305
+ const record = value as Record<string, unknown>;
306
+ const id = typeof record.id === "string" ? record.id.trim() : "";
307
+ const data = typeof record.data === "string" ? record.data.trim() : "";
308
+ if (!id || !data) return undefined;
309
+ return { id, data };
310
+ }
311
+
312
+ function parseGsapMotion(value: unknown): StudioGsapMotion | null {
313
+ if (!value || typeof value !== "object") return null;
314
+ const record = value as Record<string, unknown>;
315
+ if (record.kind !== "gsap-motion") return null;
316
+ const target = parseTarget(record.target);
317
+ if (!target) return null;
318
+ const start = finiteNumber(record.start);
319
+ const duration = finiteNumber(record.duration);
320
+ if (start == null || duration == null || start < 0 || duration <= 0) return null;
321
+ const ease = typeof record.ease === "string" && record.ease.trim() ? record.ease.trim() : "none";
322
+ const from = parseMotionValues(record.from);
323
+ const to = parseMotionValues(record.to);
324
+ if (!from || !to) return null;
325
+ return {
326
+ kind: "gsap-motion",
327
+ target,
328
+ start,
329
+ duration,
330
+ ease,
331
+ customEase: parseCustomEase(record.customEase),
332
+ from,
333
+ to,
334
+ updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
335
+ };
336
+ }
337
+
338
+ export function parseStudioMotionManifest(content: string): StudioMotionManifest {
339
+ if (!content.trim()) return emptyStudioMotionManifest();
340
+ try {
341
+ const parsed = JSON.parse(content) as unknown;
342
+ if (!parsed || typeof parsed !== "object") return emptyStudioMotionManifest();
343
+ const motions = (parsed as { motions?: unknown }).motions;
344
+ if (!Array.isArray(motions)) return emptyStudioMotionManifest();
345
+ return {
346
+ version: 1,
347
+ motions: motions
348
+ .map(parseGsapMotion)
349
+ .filter((motion): motion is StudioGsapMotion => motion !== null),
350
+ };
351
+ } catch {
352
+ return emptyStudioMotionManifest();
353
+ }
354
+ }
355
+
356
+ export function serializeStudioMotionManifest(manifest: StudioMotionManifest): string {
357
+ return `${JSON.stringify(manifest, null, 2)}\n`;
358
+ }
359
+
360
+ function normalizeStudioFileChangePath(path: string): string {
361
+ return path
362
+ .trim()
363
+ .replace(/\\/g, "/")
364
+ .replace(/^\.?\//, "");
365
+ }
366
+
367
+ export function isStudioMotionManifestPath(path: string | null): boolean {
368
+ if (!path) return false;
369
+ const normalized = normalizeStudioFileChangePath(path);
370
+ return normalized === STUDIO_MOTION_PATH || normalized.endsWith(`/${STUDIO_MOTION_PATH}`);
371
+ }
372
+
373
+ function selectionTarget(selection: DomEditSelection): StudioMotionTarget {
374
+ return {
375
+ sourceFile: selection.sourceFile || "index.html",
376
+ selector: selection.selector,
377
+ selectorIndex: selection.selectorIndex,
378
+ id: selection.id ?? undefined,
379
+ };
380
+ }
381
+
382
+ function targetKey(target: StudioMotionTarget): string {
383
+ return [
384
+ target.sourceFile,
385
+ target.id ? `id:${target.id}` : "",
386
+ target.selector ? `selector:${target.selector}` : "",
387
+ target.selectorIndex != null ? `index:${target.selectorIndex}` : "",
388
+ ].join("|");
389
+ }
390
+
391
+ function sameSelectionTarget(motion: StudioGsapMotion, selection: DomEditSelection): boolean {
392
+ const target = selectionTarget(selection);
393
+ if (motion.target.sourceFile !== target.sourceFile) return false;
394
+ if (motion.target.id && target.id && motion.target.id === target.id) return true;
395
+ return targetKey(motion.target) === targetKey(target);
396
+ }
397
+
398
+ export function upsertStudioGsapMotion(
399
+ manifest: StudioMotionManifest,
400
+ selection: DomEditSelection,
401
+ motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
402
+ ): StudioMotionManifest {
403
+ const target = selectionTarget(selection);
404
+ const nextMotion: StudioGsapMotion = {
405
+ kind: "gsap-motion",
406
+ target,
407
+ ...motion,
408
+ updatedAt: new Date().toISOString(),
409
+ };
410
+ return {
411
+ version: 1,
412
+ motions: [
413
+ ...manifest.motions.filter((existing) => targetKey(existing.target) !== targetKey(target)),
414
+ nextMotion,
415
+ ],
416
+ };
417
+ }
418
+
419
+ export function removeStudioMotionForSelection(
420
+ manifest: StudioMotionManifest,
421
+ selection: DomEditSelection,
422
+ ): StudioMotionManifest {
423
+ return {
424
+ version: 1,
425
+ motions: manifest.motions.filter((motion) => !sameSelectionTarget(motion, selection)),
426
+ };
427
+ }
428
+
429
+ export function getStudioMotionForSelection(
430
+ manifest: StudioMotionManifest,
431
+ selection: DomEditSelection,
432
+ ): StudioGsapMotion | null {
433
+ return manifest.motions.find((motion) => sameSelectionTarget(motion, selection)) ?? null;
434
+ }
435
+
436
+ function sourceFileForElement(element: HTMLElement, activeCompositionPath: string | null): string {
437
+ let current: HTMLElement | null = element;
438
+ while (current) {
439
+ const sourceFile =
440
+ current.getAttribute("data-composition-file") ?? current.getAttribute("data-composition-src");
441
+ if (sourceFile) return sourceFile;
442
+ current = current.parentElement;
443
+ }
444
+ return activeCompositionPath ?? "index.html";
445
+ }
446
+
447
+ function elementMatchesSourceFile(
448
+ element: HTMLElement,
449
+ sourceFile: string,
450
+ activeCompositionPath: string | null,
451
+ ): boolean {
452
+ return sourceFileForElement(element, activeCompositionPath) === sourceFile;
453
+ }
454
+
455
+ function querySelectorCandidates(document: Document, selector: string): HTMLElement[] {
456
+ const isCandidate = (element: Element): element is HTMLElement => {
457
+ const HTMLElementCtor = element.ownerDocument.defaultView?.HTMLElement;
458
+ return Boolean(HTMLElementCtor && element instanceof HTMLElementCtor);
459
+ };
460
+ const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
461
+ if (className) {
462
+ return Array.from(document.getElementsByTagName("*")).filter(
463
+ (element): element is HTMLElement =>
464
+ isCandidate(element) && element.classList.contains(className),
465
+ );
466
+ }
467
+ if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
468
+ return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
469
+ }
470
+ return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
471
+ }
472
+
473
+ function resolveTarget(
474
+ document: Document,
475
+ target: StudioMotionTarget,
476
+ activeCompositionPath: string | null,
477
+ ): HTMLElement | null {
478
+ const HTMLElementCtor = document.defaultView?.HTMLElement;
479
+ if (target.id) {
480
+ const byId = document.getElementById(target.id);
481
+ if (
482
+ HTMLElementCtor &&
483
+ byId instanceof HTMLElementCtor &&
484
+ elementMatchesSourceFile(byId, target.sourceFile, activeCompositionPath)
485
+ ) {
486
+ return byId;
487
+ }
488
+ }
489
+ if (!target.selector) return null;
490
+ try {
491
+ const matches = querySelectorCandidates(document, target.selector).filter((element) =>
492
+ elementMatchesSourceFile(element, target.sourceFile, activeCompositionPath),
493
+ );
494
+ return matches[Math.max(0, Math.floor(target.selectorIndex ?? 0))] ?? null;
495
+ } catch {
496
+ return null;
497
+ }
498
+ }
499
+
500
+ function captureOriginalMotionStyles(element: HTMLElement): void {
501
+ if (element.hasAttribute(STUDIO_MOTION_ATTR)) return;
502
+ element.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, element.style.transform);
503
+ element.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, element.style.opacity);
504
+ element.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, element.style.visibility);
505
+ }
506
+
507
+ function restoreStudioMotionElement(element: HTMLElement, gsap: StudioMotionWindow["gsap"]): void {
508
+ if (!element.hasAttribute(STUDIO_MOTION_ATTR)) return;
509
+ gsap?.set?.(element, { clearProps: "transform,opacity,visibility" });
510
+ element.style.transform = element.getAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR) ?? "";
511
+ element.style.opacity = element.getAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR) ?? "";
512
+ element.style.visibility = element.getAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR) ?? "";
513
+ element.removeAttribute(STUDIO_MOTION_ATTR);
514
+ element.removeAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR);
515
+ element.removeAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR);
516
+ element.removeAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR);
517
+ }
518
+
519
+ function restoreStudioMotionElements(document: Document, gsap: StudioMotionWindow["gsap"]): void {
520
+ const HTMLElementCtor = document.defaultView?.HTMLElement;
521
+ if (!HTMLElementCtor) return;
522
+ for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
523
+ if (element instanceof HTMLElementCtor) restoreStudioMotionElement(element, gsap);
524
+ }
525
+ }
526
+
527
+ function resolveGsapEase(win: StudioMotionWindow, motion: StudioGsapMotion): string {
528
+ const customEase = motion.customEase;
529
+ if (!customEase) return motion.ease;
530
+ const customEasePlugin = win.CustomEase;
531
+ if (typeof customEasePlugin?.create !== "function") return motion.ease;
532
+ try {
533
+ win.gsap?.registerPlugin?.(customEasePlugin);
534
+ customEasePlugin.create(customEase.id, customEase.data);
535
+ return customEase.id;
536
+ } catch {
537
+ return motion.ease;
538
+ }
539
+ }
540
+
541
+ function readCurrentTime(win: StudioMotionWindow, fallback?: number): number {
542
+ if (typeof fallback === "number" && Number.isFinite(fallback)) return Math.max(0, fallback);
543
+ try {
544
+ const playerTime = win.__player?.getTime?.();
545
+ if (typeof playerTime === "number" && Number.isFinite(playerTime))
546
+ return Math.max(0, playerTime);
547
+ } catch {
548
+ // fall through
549
+ }
550
+ try {
551
+ const timelineTime = win.__timeline?.time?.();
552
+ if (typeof timelineTime === "number" && Number.isFinite(timelineTime)) {
553
+ return Math.max(0, timelineTime);
554
+ }
555
+ } catch {
556
+ // fall through
557
+ }
558
+ return 0;
559
+ }
560
+
561
+ export function applyStudioMotionManifest(
562
+ document: Document,
563
+ manifest: StudioMotionManifest,
564
+ activeCompositionPath: string | null = null,
565
+ currentTime?: number,
566
+ ): number {
567
+ const win = document.defaultView as StudioMotionWindow | null;
568
+ if (!win) return 0;
569
+ const gsap = win.gsap;
570
+ win.__timelines = win.__timelines ?? {};
571
+ win.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.();
572
+ delete win.__timelines[STUDIO_MOTION_TIMELINE_ID];
573
+ restoreStudioMotionElements(document, gsap);
574
+ if (!gsap?.timeline || manifest.motions.length === 0) return 0;
575
+
576
+ const timeline = gsap.timeline({
577
+ paused: true,
578
+ defaults: { overwrite: "auto" },
579
+ });
580
+ let applied = 0;
581
+ for (const motion of manifest.motions) {
582
+ const element = resolveTarget(document, motion.target, activeCompositionPath);
583
+ if (!element || !timeline.fromTo) continue;
584
+ captureOriginalMotionStyles(element);
585
+ element.setAttribute(STUDIO_MOTION_ATTR, "true");
586
+ const fromVars: Record<string, unknown> = { ...motion.from };
587
+ const toVars: Record<string, unknown> = {
588
+ ...motion.to,
589
+ duration: motion.duration,
590
+ ease: resolveGsapEase(win, motion),
591
+ overwrite: "auto",
592
+ immediateRender: false,
593
+ };
594
+ timeline.fromTo(element, fromVars, toVars, motion.start);
595
+ applied += 1;
596
+ }
597
+
598
+ if (applied === 0) {
599
+ timeline.kill?.();
600
+ return 0;
601
+ }
602
+ win.__timelines[STUDIO_MOTION_TIMELINE_ID] = timeline;
603
+ timeline.pause?.();
604
+ const safeTime = readCurrentTime(win, currentTime);
605
+ if (timeline.totalTime) timeline.totalTime(safeTime, false);
606
+ else timeline.time?.(safeTime);
607
+ return applied;
608
+ }
609
+
610
+ export function installStudioMotionSeekReapply(win: Window, apply: () => void): boolean {
611
+ const studioWin = win as StudioMotionWindow;
612
+ studioWin.__hfStudioMotionApply = () => {
613
+ apply();
614
+ return 0;
615
+ };
616
+ if (studioWin.__hfStudioMotionWrapped) return false;
617
+ const player = studioWin.__player;
618
+ if (!player) return false;
619
+
620
+ const wrapPlayerMethod = (key: "renderSeek" | "seek") => {
621
+ const original = player[key];
622
+ if (typeof original !== "function") return;
623
+ player[key] = (time: number) => {
624
+ original.call(player, time);
625
+ studioWin.__hfStudioMotionApply?.();
626
+ };
627
+ };
628
+ wrapPlayerMethod("renderSeek");
629
+ wrapPlayerMethod("seek");
630
+ studioWin.__hfStudioMotionWrapped = true;
631
+ return true;
632
+ }
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldDisableTimelineWhileCompositionLoading } from "./NLELayout";
3
+
4
+ describe("timeline loading disable state", () => {
5
+ it("disables the timeline while the composition loading overlay is visible", () => {
6
+ expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true);
7
+ });
8
+
9
+ it("reenables the timeline after composition loading finishes", () => {
10
+ expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false);
11
+ });
12
+ });