@hyperframes/studio 0.5.4 → 0.6.0-alpha.1

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 (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-D04_ZoMm.js +107 -0
  3. package/dist/assets/index-UWFaHilT.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +120 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.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
+ }
@@ -51,6 +51,12 @@ interface NLELayoutProps {
51
51
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
52
52
  ) => Promise<void> | void;
53
53
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
54
+ onSelectTimelineElement?: (element: TimelineElement | null) => void;
55
+ onInspectTimelineElement?: (element: TimelineElement) => void;
56
+ inspectedTimelineElementId?: string | null;
57
+ timelineLayerChildCounts?: ReadonlyMap<string, number>;
58
+ thumbnailedTimelineElementIds?: ReadonlySet<string>;
59
+ onToggleTimelineElementThumbnail?: (element: TimelineElement) => void;
54
60
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
55
61
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
56
62
  /** Whether the timeline panel is visible (default: true) */
@@ -80,6 +86,12 @@ export const NLELayout = memo(function NLELayout({
80
86
  onMoveElement,
81
87
  onResizeElement,
82
88
  onBlockedEditAttempt,
89
+ onSelectTimelineElement,
90
+ onInspectTimelineElement,
91
+ inspectedTimelineElementId,
92
+ timelineLayerChildCounts,
93
+ thumbnailedTimelineElementIds,
94
+ onToggleTimelineElementThumbnail,
83
95
  onCompIdToSrcChange,
84
96
  timelineVisible,
85
97
  onToggleTimeline,
@@ -89,7 +101,6 @@ export const NLELayout = memo(function NLELayout({
89
101
  togglePlay,
90
102
  seek,
91
103
  onIframeLoad: baseOnIframeLoad,
92
- refreshPlayer,
93
104
  saveSeekPosition,
94
105
  } = useTimelinePlayer();
95
106
 
@@ -103,13 +114,15 @@ export const NLELayout = memo(function NLELayout({
103
114
  usePlayerStore.getState().reset();
104
115
  }
105
116
 
106
- // Refresh the existing iframe in place when source files change.
117
+ // Save seek position before the Player component creates a new player
118
+ // on refreshKey change. The Player handles the actual reload via the
119
+ // dual-player crossfade; we just need to persist the current time.
107
120
  const prevRefreshKeyRef = useRef(refreshKey);
108
121
  useEffect(() => {
109
122
  if (refreshKey === prevRefreshKeyRef.current) return;
110
123
  prevRefreshKeyRef.current = refreshKey;
111
- refreshPlayer();
112
- }, [refreshKey, refreshPlayer]);
124
+ saveSeekPosition();
125
+ }, [refreshKey, saveSeekPosition]);
113
126
 
114
127
  // Wrap onIframeLoad to also notify parent of iframe ref
115
128
  const onIframeLoad = useCallback(() => {
@@ -209,6 +222,10 @@ export const NLELayout = memo(function NLELayout({
209
222
  const currentLevel = compositionStack[compositionStack.length - 1];
210
223
  const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
211
224
 
225
+ useEffect(() => {
226
+ onIframeRef?.(iframeRef.current);
227
+ }, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
228
+
212
229
  // Save master seek position before drilling down so we can restore it on back-navigation.
213
230
  // saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
214
231
  const masterSeekRef = useRef(0);
@@ -412,6 +429,12 @@ export const NLELayout = memo(function NLELayout({
412
429
  onMoveElement={onMoveElement}
413
430
  onResizeElement={onResizeElement}
414
431
  onBlockedEditAttempt={onBlockedEditAttempt}
432
+ onSelectElement={onSelectTimelineElement}
433
+ onInspectElement={onInspectTimelineElement}
434
+ inspectedElementId={inspectedTimelineElementId}
435
+ layerChildCounts={timelineLayerChildCounts}
436
+ thumbnailedElementIds={thumbnailedTimelineElementIds}
437
+ onToggleElementThumbnail={onToggleTimelineElementThumbnail}
415
438
  />
416
439
  </div>
417
440
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}