@hyperframes/studio 0.6.0-alpha.9 → 0.6.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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,472 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { usePlayerStore } from "../player";
4
+ import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
5
+ import {
6
+ buildTrackZIndexMap,
7
+ formatTimelineAttributeNumber,
8
+ } from "../player/components/timelineEditing";
9
+ import {
10
+ buildTimelineAssetId,
11
+ buildTimelineAssetInsertHtml,
12
+ buildTimelineFileDropPlacements,
13
+ getTimelineAssetKind,
14
+ insertTimelineAssetIntoSource,
15
+ resolveTimelineAssetInitialGeometry,
16
+ resolveTimelineAssetSrc,
17
+ } from "../utils/timelineAssetDrop";
18
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
19
+ import {
20
+ getTimelineElementLabel,
21
+ collectHtmlIds,
22
+ resolveDroppedAssetDuration,
23
+ } from "../utils/studioHelpers";
24
+ import type { EditHistoryKind } from "../utils/editHistory";
25
+
26
+ // ── Types ──
27
+
28
+ interface RecordEditInput {
29
+ label: string;
30
+ kind: EditHistoryKind;
31
+ coalesceKey?: string;
32
+ files: Record<string, { before: string; after: string }>;
33
+ }
34
+
35
+ interface UseTimelineEditingOptions {
36
+ projectId: string | null;
37
+ activeCompPath: string | null;
38
+ timelineElements: TimelineElement[];
39
+ showToast: (message: string, tone?: "error" | "info") => void;
40
+ writeProjectFile: (path: string, content: string) => Promise<void>;
41
+ recordEdit: (input: RecordEditInput) => Promise<void>;
42
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
43
+ reloadPreview: () => void;
44
+ uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
45
+ }
46
+
47
+ // ── Helpers ──
48
+
49
+ function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) {
50
+ if (element.domId) {
51
+ return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex };
52
+ }
53
+ if (element.selector) {
54
+ return { selector: element.selector, selectorIndex: element.selectorIndex };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ async function readFileContent(projectId: string, targetPath: string): Promise<string> {
60
+ const response = await fetch(
61
+ `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
62
+ );
63
+ if (!response.ok) {
64
+ throw new Error(`Failed to read ${targetPath}`);
65
+ }
66
+ const data = (await response.json()) as { content?: string };
67
+ if (typeof data.content !== "string") {
68
+ throw new Error(`Missing file contents for ${targetPath}`);
69
+ }
70
+ return data.content;
71
+ }
72
+
73
+ // ── Hook ──
74
+
75
+ export function useTimelineEditing({
76
+ projectId,
77
+ activeCompPath,
78
+ timelineElements,
79
+ showToast,
80
+ writeProjectFile,
81
+ recordEdit,
82
+ domEditSaveTimestampRef,
83
+ reloadPreview,
84
+ uploadProjectFiles,
85
+ }: UseTimelineEditingOptions) {
86
+ const projectIdRef = useRef(projectId);
87
+ projectIdRef.current = projectId;
88
+
89
+ const lastBlockedTimelineToastAtRef = useRef(0);
90
+
91
+ const handleTimelineElementMove = useCallback(
92
+ async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
93
+ const pid = projectIdRef.current;
94
+ if (!pid) throw new Error("No active project");
95
+
96
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
97
+ const originalContent = await readFileContent(pid, targetPath);
98
+
99
+ const patchTarget = buildPatchTarget(element);
100
+ if (!patchTarget) {
101
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
102
+ }
103
+
104
+ const resolvedTargetPath = targetPath || "index.html";
105
+ const relevantElements = timelineElements
106
+ .map((te) =>
107
+ (te.key ?? te.id) === (element.key ?? element.id)
108
+ ? { ...te, start: updates.start, track: updates.track }
109
+ : te,
110
+ )
111
+ .filter((te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath);
112
+ const trackZIndices = buildTrackZIndexMap(relevantElements.map((te) => te.track));
113
+
114
+ let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
115
+ type: "attribute",
116
+ property: "start",
117
+ value: formatTimelineAttributeNumber(updates.start),
118
+ });
119
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
120
+ type: "attribute",
121
+ property: "track-index",
122
+ value: String(updates.track),
123
+ });
124
+ for (const te of relevantElements) {
125
+ const elementTarget = buildPatchTarget(te);
126
+ if (!elementTarget) continue;
127
+ const nextZIndex = trackZIndices.get(te.track);
128
+ if (nextZIndex == null) continue;
129
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
130
+ type: "inline-style",
131
+ property: "z-index",
132
+ value: String(nextZIndex),
133
+ });
134
+ }
135
+
136
+ if (patchedContent === originalContent) {
137
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
138
+ }
139
+
140
+ domEditSaveTimestampRef.current = Date.now();
141
+ await saveProjectFilesWithHistory({
142
+ projectId: pid,
143
+ label: "Move timeline clip",
144
+ kind: "timeline",
145
+ files: { [targetPath]: patchedContent },
146
+ readFile: async () => originalContent,
147
+ writeFile: writeProjectFile,
148
+ recordEdit,
149
+ });
150
+
151
+ reloadPreview();
152
+ },
153
+ [
154
+ activeCompPath,
155
+ recordEdit,
156
+ timelineElements,
157
+ writeProjectFile,
158
+ domEditSaveTimestampRef,
159
+ reloadPreview,
160
+ ],
161
+ );
162
+
163
+ const handleTimelineElementResize = useCallback(
164
+ async (
165
+ element: TimelineElement,
166
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
167
+ ) => {
168
+ const pid = projectIdRef.current;
169
+ if (!pid) throw new Error("No active project");
170
+
171
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
172
+ const originalContent = await readFileContent(pid, targetPath);
173
+
174
+ const patchTarget = buildPatchTarget(element);
175
+ if (!patchTarget) {
176
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
177
+ }
178
+
179
+ const playbackStartAttrName =
180
+ element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
181
+ const currentPlaybackStartValue =
182
+ readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
183
+ readAttributeByTarget(originalContent, patchTarget, "media-start");
184
+ const currentPlaybackStart =
185
+ currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
186
+ const trimDelta = updates.start - element.start;
187
+ const fallbackPlaybackStart =
188
+ updates.playbackStart == null &&
189
+ trimDelta !== 0 &&
190
+ Number.isFinite(currentPlaybackStart) &&
191
+ currentPlaybackStart != null
192
+ ? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
193
+ : undefined;
194
+ const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
195
+
196
+ let patchedContent = originalContent;
197
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
198
+ type: "attribute",
199
+ property: "start",
200
+ value: formatTimelineAttributeNumber(updates.start),
201
+ });
202
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
203
+ type: "attribute",
204
+ property: "duration",
205
+ value: formatTimelineAttributeNumber(updates.duration),
206
+ });
207
+ if (nextPlaybackStart != null) {
208
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
209
+ type: "attribute",
210
+ property: playbackStartAttrName,
211
+ value: formatTimelineAttributeNumber(nextPlaybackStart),
212
+ });
213
+ }
214
+
215
+ if (patchedContent === originalContent) {
216
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
217
+ }
218
+
219
+ domEditSaveTimestampRef.current = Date.now();
220
+ await saveProjectFilesWithHistory({
221
+ projectId: pid,
222
+ label: "Resize timeline clip",
223
+ kind: "timeline",
224
+ files: { [targetPath]: patchedContent },
225
+ readFile: async () => originalContent,
226
+ writeFile: writeProjectFile,
227
+ recordEdit,
228
+ });
229
+
230
+ reloadPreview();
231
+ },
232
+ [activeCompPath, recordEdit, writeProjectFile, domEditSaveTimestampRef, reloadPreview],
233
+ );
234
+
235
+ const handleTimelineElementDelete = useCallback(
236
+ async (element: TimelineElement) => {
237
+ const pid = projectIdRef.current;
238
+ if (!pid) throw new Error("No active project");
239
+ const label = getTimelineElementLabel(element);
240
+
241
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
242
+ try {
243
+ const originalContent = await readFileContent(pid, targetPath);
244
+
245
+ const patchTarget = buildPatchTarget(element);
246
+ if (!patchTarget) {
247
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
248
+ }
249
+
250
+ const resolvedTargetPath = targetPath || "index.html";
251
+ const remainingElements = timelineElements.filter(
252
+ (te) =>
253
+ (te.key ?? te.id) !== (element.key ?? element.id) &&
254
+ (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
255
+ );
256
+ const trackZIndices = buildTrackZIndexMap(remainingElements.map((te) => te.track));
257
+
258
+ const removeResponse = await fetch(
259
+ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
260
+ {
261
+ method: "POST",
262
+ headers: { "Content-Type": "application/json" },
263
+ body: JSON.stringify({ target: patchTarget }),
264
+ },
265
+ );
266
+ if (!removeResponse.ok) {
267
+ throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
268
+ }
269
+
270
+ const removeData = (await removeResponse.json()) as {
271
+ changed?: boolean;
272
+ content?: string;
273
+ };
274
+ let patchedContent =
275
+ typeof removeData.content === "string" ? removeData.content : originalContent;
276
+ for (const te of remainingElements) {
277
+ const elementTarget = buildPatchTarget(te);
278
+ if (!elementTarget) continue;
279
+ const nextZIndex = trackZIndices.get(te.track);
280
+ if (nextZIndex == null) continue;
281
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
282
+ type: "inline-style",
283
+ property: "z-index",
284
+ value: String(nextZIndex),
285
+ });
286
+ }
287
+
288
+ domEditSaveTimestampRef.current = Date.now();
289
+ await saveProjectFilesWithHistory({
290
+ projectId: pid,
291
+ label: "Delete timeline clip",
292
+ kind: "timeline",
293
+ files: { [targetPath]: patchedContent },
294
+ readFile: async () => originalContent,
295
+ writeFile: writeProjectFile,
296
+ recordEdit,
297
+ });
298
+
299
+ usePlayerStore
300
+ .getState()
301
+ .setElements(
302
+ timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
303
+ );
304
+ usePlayerStore.getState().setSelectedElementId(null);
305
+ reloadPreview();
306
+ showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
307
+ } catch (error) {
308
+ const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
309
+ showToast(message);
310
+ }
311
+ },
312
+ [
313
+ activeCompPath,
314
+ recordEdit,
315
+ showToast,
316
+ timelineElements,
317
+ writeProjectFile,
318
+ domEditSaveTimestampRef,
319
+ reloadPreview,
320
+ ],
321
+ );
322
+
323
+ const handleTimelineAssetDrop = useCallback(
324
+ async (
325
+ assetPath: string,
326
+ placement: Pick<TimelineElement, "start" | "track">,
327
+ durationOverride?: number,
328
+ ) => {
329
+ const pid = projectIdRef.current;
330
+ if (!pid) throw new Error("No active project");
331
+
332
+ const kind = getTimelineAssetKind(assetPath);
333
+ if (!kind) {
334
+ showToast("Only image, video, and audio assets can be dropped onto the timeline.");
335
+ return;
336
+ }
337
+
338
+ const targetPath = activeCompPath || "index.html";
339
+ try {
340
+ const originalContent = await readFileContent(pid, targetPath);
341
+
342
+ const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
343
+ const duration =
344
+ Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
345
+ ? durationOverride
346
+ : await resolveDroppedAssetDuration(pid, assetPath, kind);
347
+ const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
348
+ const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
349
+ const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
350
+
351
+ const resolvedTargetPath = targetPath || "index.html";
352
+ const relevantElements = timelineElements.filter(
353
+ (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
354
+ );
355
+ const trackZIndices = buildTrackZIndexMap([
356
+ ...relevantElements.map((te) => te.track),
357
+ placement.track,
358
+ ]);
359
+
360
+ let patchedContent = originalContent;
361
+ for (const te of relevantElements) {
362
+ const elementTarget = buildPatchTarget(te);
363
+ if (!elementTarget) continue;
364
+ const nextZIndex = trackZIndices.get(te.track);
365
+ if (nextZIndex == null) continue;
366
+ patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
367
+ type: "inline-style",
368
+ property: "z-index",
369
+ value: String(nextZIndex),
370
+ });
371
+ }
372
+
373
+ patchedContent = insertTimelineAssetIntoSource(
374
+ patchedContent,
375
+ buildTimelineAssetInsertHtml({
376
+ id: newId,
377
+ assetPath: resolvedAssetSrc,
378
+ kind,
379
+ start: normalizedStart,
380
+ duration: normalizedDuration,
381
+ track: placement.track,
382
+ zIndex: trackZIndices.get(placement.track) ?? 1,
383
+ geometry: resolveTimelineAssetInitialGeometry(originalContent),
384
+ }),
385
+ );
386
+
387
+ domEditSaveTimestampRef.current = Date.now();
388
+ await saveProjectFilesWithHistory({
389
+ projectId: pid,
390
+ label: "Add timeline asset",
391
+ kind: "timeline",
392
+ files: { [targetPath]: patchedContent },
393
+ readFile: async () => originalContent,
394
+ writeFile: writeProjectFile,
395
+ recordEdit,
396
+ });
397
+
398
+ reloadPreview();
399
+ } catch (error) {
400
+ const message =
401
+ error instanceof Error ? error.message : "Failed to drop asset onto timeline";
402
+ showToast(message);
403
+ }
404
+ },
405
+ [
406
+ activeCompPath,
407
+ recordEdit,
408
+ showToast,
409
+ timelineElements,
410
+ writeProjectFile,
411
+ domEditSaveTimestampRef,
412
+ reloadPreview,
413
+ ],
414
+ );
415
+
416
+ const handleTimelineFileDrop = useCallback(
417
+ async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
418
+ const pid = projectIdRef.current;
419
+ if (!pid) return;
420
+ const uploaded = await uploadProjectFiles(files);
421
+ if (uploaded.length === 0) return;
422
+ const durations: number[] = [];
423
+ for (const assetPath of uploaded) {
424
+ const kind = getTimelineAssetKind(assetPath);
425
+ const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
426
+ durations.push(Number(formatTimelineAttributeNumber(duration)));
427
+ }
428
+ const placements = buildTimelineFileDropPlacements(
429
+ placement ?? { start: 0, track: 0 },
430
+ durations,
431
+ timelineElements
432
+ .filter(
433
+ (te) =>
434
+ (te.sourceFile || activeCompPath || "index.html") ===
435
+ (activeCompPath || "index.html"),
436
+ )
437
+ .map((te) => ({
438
+ start: te.start,
439
+ duration: te.duration,
440
+ track: te.track,
441
+ })),
442
+ );
443
+ for (const [index, assetPath] of uploaded.entries()) {
444
+ await handleTimelineAssetDrop(
445
+ assetPath,
446
+ placements[index] ?? placements[0],
447
+ durations[index],
448
+ );
449
+ }
450
+ },
451
+ [activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
452
+ );
453
+
454
+ const handleBlockedTimelineEdit = useCallback(
455
+ (_element: TimelineElement) => {
456
+ const now = Date.now();
457
+ if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
458
+ lastBlockedTimelineToastAtRef.current = now;
459
+ showToast("This clip can't be moved or resized from the timeline yet.", "info");
460
+ },
461
+ [showToast],
462
+ );
463
+
464
+ return {
465
+ handleTimelineElementMove,
466
+ handleTimelineElementResize,
467
+ handleTimelineElementDelete,
468
+ handleTimelineAssetDrop,
469
+ handleTimelineFileDrop,
470
+ handleBlockedTimelineEdit,
471
+ };
472
+ }
@@ -0,0 +1,20 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+ import { useMountEffect } from "./useMountEffect";
3
+ import type { AppToast } from "../utils/studioHelpers";
4
+
5
+ export function useToast() {
6
+ const [appToast, setAppToast] = useState<AppToast | null>(null);
7
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
8
+
9
+ const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
10
+ if (timerRef.current) clearTimeout(timerRef.current);
11
+ setAppToast({ message, tone });
12
+ timerRef.current = setTimeout(() => setAppToast(null), 4000);
13
+ }, []);
14
+
15
+ useMountEffect(() => () => {
16
+ if (timerRef.current) clearTimeout(timerRef.current);
17
+ });
18
+
19
+ return { appToast, showToast };
20
+ }
@@ -13,6 +13,7 @@ interface PlayerProps {
13
13
  onCompositionLoadingChange?: (loading: boolean) => void;
14
14
  portrait?: boolean;
15
15
  style?: React.CSSProperties;
16
+ suppressLoadingOverlay?: boolean;
16
17
  }
17
18
 
18
19
  interface HyperframesPlayerElement extends HTMLElement {
@@ -35,6 +36,8 @@ function getShaderTransitionLoading(event: Event): boolean | null {
35
36
  return state.loading === true && state.ready !== true;
36
37
  }
37
38
 
39
+ const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
40
+
38
41
  export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
39
42
  return compositionLoading;
40
43
  }
@@ -102,7 +105,18 @@ export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean
102
105
  * timeline probing, and DOM inspection.
103
106
  */
104
107
  export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
105
- ({ projectId, directUrl, onLoad, onCompositionLoadingChange, portrait, style }, ref) => {
108
+ (
109
+ {
110
+ projectId,
111
+ directUrl,
112
+ onLoad,
113
+ onCompositionLoadingChange,
114
+ portrait,
115
+ style,
116
+ suppressLoadingOverlay,
117
+ },
118
+ ref,
119
+ ) => {
106
120
  const containerRef = useRef<HTMLDivElement>(null);
107
121
  const loadCountRef = useRef(0);
108
122
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -112,6 +126,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
112
126
  const [assetOverlayFading, setAssetOverlayFading] = useState(false);
113
127
  const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
114
128
  const [compositionLoading, setCompositionLoading] = useState(true);
129
+ const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
130
+
131
+ // eslint-disable-next-line no-restricted-syntax
132
+ useEffect(() => {
133
+ if (!compositionLoading) {
134
+ setCompositionOverlayDeferred(true);
135
+ return;
136
+ }
137
+ const timer = setTimeout(
138
+ () => setCompositionOverlayDeferred(false),
139
+ COMPOSITION_LOADING_OVERLAY_DELAY_MS,
140
+ );
141
+ return () => clearTimeout(timer);
142
+ }, [compositionLoading]);
115
143
 
116
144
  useMountEffect(() => {
117
145
  const container = containerRef.current;
@@ -268,7 +296,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
268
296
  };
269
297
  }, [assetsLoading]);
270
298
 
271
- const showCompositionOverlay = shouldShowCompositionLoadingOverlay(compositionLoading);
299
+ const showCompositionOverlay =
300
+ !suppressLoadingOverlay &&
301
+ !compositionOverlayDeferred &&
302
+ shouldShowCompositionLoadingOverlay(compositionLoading);
272
303
  const showAssetOverlay =
273
304
  assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
274
305
 
@@ -12,8 +12,6 @@ import {
12
12
  shouldHandleTimelineDeleteKey,
13
13
  shouldAutoScrollTimeline,
14
14
  } from "./Timeline";
15
- import { TIMELINE_CLIP_CONTROL_Z_INDEX } from "./TimelineClip";
16
- import { COMPOSITION_THUMBNAIL_LABEL_Z_INDEX } from "./CompositionThumbnail";
17
15
  import { formatTime } from "../lib/time";
18
16
 
19
17
  describe("generateTicks", () => {
@@ -166,12 +164,6 @@ describe("shouldAutoScrollTimeline", () => {
166
164
  });
167
165
  });
168
166
 
169
- describe("timeline clip controls", () => {
170
- it("renders layer controls above composition thumbnail chrome", () => {
171
- expect(TIMELINE_CLIP_CONTROL_Z_INDEX).toBeGreaterThan(COMPOSITION_THUMBNAIL_LABEL_Z_INDEX);
172
- });
173
- });
174
-
175
167
  describe("getTimelineScrollLeftForZoomTransition", () => {
176
168
  it("resets horizontal scroll when switching from manual zoom back to fit", () => {
177
169
  expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);