@hyperframes/studio 0.7.37 → 0.7.39

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 (109) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-0P10SwC_.css +1 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
  4. package/dist/assets/index-B9YvRJz1.js +396 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
  6. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  7. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  8. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  9. package/dist/index.d.ts +48 -10
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6045 -3507
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +26 -24
  15. package/src/components/AskAgentModal.tsx +23 -6
  16. package/src/components/LintModal.tsx +40 -10
  17. package/src/components/MediaPreview.tsx +36 -1
  18. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  19. package/src/components/StudioErrorBoundary.tsx +19 -31
  20. package/src/components/StudioFeedbackBar.tsx +11 -2
  21. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  22. package/src/components/StudioHeader.tsx +184 -106
  23. package/src/components/StudioLeftSidebar.tsx +24 -3
  24. package/src/components/StudioOverlays.tsx +34 -8
  25. package/src/components/StudioPreviewArea.tsx +8 -0
  26. package/src/components/StudioRightPanel.tsx +160 -9
  27. package/src/components/StudioSplash.tsx +6 -3
  28. package/src/components/StudioToast.tsx +12 -11
  29. package/src/components/TimelineToolbar.test.tsx +9 -3
  30. package/src/components/TimelineToolbar.tsx +63 -29
  31. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  32. package/src/components/editor/DomEditOverlay.tsx +111 -108
  33. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  34. package/src/components/editor/PropertyPanel.test.ts +48 -0
  35. package/src/components/editor/PropertyPanel.tsx +27 -47
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  37. package/src/components/editor/SnapToolbar.tsx +20 -1
  38. package/src/components/editor/clipPathHelpers.ts +113 -0
  39. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  40. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  41. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  42. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  43. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  44. package/src/components/editor/domEditOverlayShape.ts +39 -0
  45. package/src/components/editor/domEditing.test.ts +2 -0
  46. package/src/components/editor/domEditingTypes.ts +3 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  48. package/src/components/editor/manualEditingAvailability.ts +0 -6
  49. package/src/components/editor/marqueeCommit.ts +3 -2
  50. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  51. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  52. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  53. package/src/components/editor/propertyPanelHelpers.ts +32 -125
  54. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  55. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  56. package/src/components/editor/propertyPanelTypes.ts +113 -0
  57. package/src/components/editor/snapTargetCollection.ts +2 -3
  58. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  59. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  60. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  61. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  62. package/src/components/nle/NLELayout.tsx +54 -51
  63. package/src/components/nle/NLEPreview.tsx +41 -5
  64. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  65. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  66. package/src/components/renders/RenderQueue.tsx +125 -24
  67. package/src/components/renders/RenderQueueItem.tsx +132 -64
  68. package/src/components/renders/useRenderQueue.ts +136 -24
  69. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  70. package/src/components/sidebar/AssetsTab.tsx +113 -15
  71. package/src/components/sidebar/BlocksTab.tsx +2 -1
  72. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  73. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  74. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  75. package/src/components/storyboard/StoryboardView.tsx +6 -0
  76. package/src/components/studioColorGradingScope.ts +124 -0
  77. package/src/components/studioMediaJobs.ts +123 -0
  78. package/src/components/ui/Button.tsx +20 -11
  79. package/src/components/ui/HyperframesLoader.tsx +9 -2
  80. package/src/components/ui/SearchInput.tsx +53 -0
  81. package/src/components/ui/Tooltip.tsx +52 -6
  82. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  83. package/src/components/ui/useDialogBehavior.ts +83 -0
  84. package/src/contexts/PanelLayoutContext.tsx +3 -0
  85. package/src/contexts/StudioContext.tsx +7 -0
  86. package/src/hooks/domSelectionTestHarness.ts +1 -0
  87. package/src/hooks/useCompositionContentLoader.ts +40 -0
  88. package/src/hooks/useCropMode.ts +91 -0
  89. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  90. package/src/hooks/useEditorSave.ts +14 -0
  91. package/src/hooks/useFileManager.ts +39 -25
  92. package/src/hooks/useFrameCapture.ts +12 -1
  93. package/src/hooks/usePanelLayout.ts +1 -0
  94. package/src/hooks/usePreviewPersistence.ts +24 -15
  95. package/src/hooks/useStudioContextValue.ts +5 -0
  96. package/src/hooks/useToast.ts +66 -13
  97. package/src/player/components/ShortcutsPanel.tsx +9 -0
  98. package/src/player/store/playerStore.ts +14 -0
  99. package/src/styles/studio.css +61 -0
  100. package/src/utils/sdkResolverShadow.test.ts +53 -3
  101. package/src/utils/sdkResolverShadow.ts +41 -1
  102. package/src/utils/studioPendingEdits.test.ts +43 -0
  103. package/src/utils/studioPendingEdits.ts +45 -0
  104. package/src/utils/studioUiPreferences.ts +4 -0
  105. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  106. package/dist/assets/index-DmkOvZns.css +0 -1
  107. package/dist/assets/index-SYSbQyK4.js +0 -396
  108. package/dist/chunk-JND3XUJL.js.map +0 -1
  109. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
@@ -0,0 +1,68 @@
1
+ import { useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+
4
+ export interface DomEditCompositionRect {
5
+ left: number;
6
+ top: number;
7
+ width: number;
8
+ height: number;
9
+ scaleX: number;
10
+ scaleY: number;
11
+ }
12
+
13
+ function sameRect(a: DomEditCompositionRect, b: DomEditCompositionRect): boolean {
14
+ const d = (k: keyof DomEditCompositionRect) => Math.abs(a[k] - b[k]);
15
+ return (
16
+ d("left") < 0.5 &&
17
+ d("top") < 0.5 &&
18
+ d("width") < 0.5 &&
19
+ d("height") < 0.5 &&
20
+ d("scaleX") < 0.001 &&
21
+ d("scaleY") < 0.001
22
+ );
23
+ }
24
+
25
+ export function useDomEditCompositionRect({
26
+ iframeRef,
27
+ overlayRef,
28
+ }: {
29
+ iframeRef: RefObject<HTMLIFrameElement | null>;
30
+ overlayRef: RefObject<HTMLDivElement | null>;
31
+ }): DomEditCompositionRect {
32
+ const [compRect, setCompRect] = useState({
33
+ left: 0,
34
+ top: 0,
35
+ width: 0,
36
+ height: 0,
37
+ scaleX: 1,
38
+ scaleY: 1,
39
+ });
40
+
41
+ useMountEffect(() => {
42
+ let frame = 0;
43
+ // fallow-ignore-next-line complexity
44
+ const update = () => {
45
+ frame = requestAnimationFrame(update);
46
+ const iframe = iframeRef.current;
47
+ const overlayEl = overlayRef.current;
48
+ if (!iframe || !overlayEl) return;
49
+ const iRect = iframe.getBoundingClientRect();
50
+ const oRect = overlayEl.getBoundingClientRect();
51
+ const left = iRect.left - oRect.left;
52
+ const top = iRect.top - oRect.top;
53
+ if (iRect.width <= 0 || iRect.height <= 0) return;
54
+ const doc = iframe.contentDocument;
55
+ const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
56
+ const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
57
+ const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
58
+ const scaleX = dw > 0 ? iRect.width / dw : 1;
59
+ const scaleY = dh > 0 ? iRect.height / dh : 1;
60
+ const next = { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
61
+ setCompRect((prev) => (sameRect(prev, next) ? prev : next));
62
+ };
63
+ frame = requestAnimationFrame(update);
64
+ return () => cancelAnimationFrame(frame);
65
+ });
66
+
67
+ return compRect;
68
+ }
@@ -46,6 +46,7 @@ import {
46
46
  startGesture as _startGesture,
47
47
  startGroupDrag as _startGroupDrag,
48
48
  } from "./domEditOverlayStartGesture";
49
+ import { hugRectForElement } from "./domEditOverlayCrop";
49
50
  import {
50
51
  resolveSnapAdjustment,
51
52
  resolveResizeSnapAdjustment,
@@ -183,12 +184,18 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
183
184
  if (g.kind === "drag") {
184
185
  const sc = g.snapContext;
185
186
  if (sc?.snapEnabled && sc.targets.length > 0) {
186
- const movingRect = {
187
- left: g.originLeft,
188
- top: g.originTop,
189
- width: g.originWidth,
190
- height: g.originHeight,
191
- };
187
+ // Snap the element's VISIBLE (crop-hugged) edges, not the full bounds.
188
+ const movingRect = hugRectForElement(
189
+ {
190
+ left: g.originLeft,
191
+ top: g.originTop,
192
+ width: g.originWidth,
193
+ height: g.originHeight,
194
+ editScaleX: g.editScaleX,
195
+ editScaleY: g.editScaleY,
196
+ },
197
+ g.selection.element,
198
+ );
192
199
  const allTargets = sc.compositionTarget
193
200
  ? [...sc.targets, sc.compositionTarget]
194
201
  : sc.targets;
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { useRef, useState, type RefObject } from "react";
6
6
  import { useMountEffect } from "../../hooks/useMountEffect";
7
+ import { hugRectForElement } from "./domEditOverlayCrop";
7
8
  import { type DomEditSelection, findElementForSelection } from "./domEditing";
8
9
  import {
9
10
  type GroupOverlayItem,
@@ -15,7 +16,7 @@ import {
15
16
  rectsEqual,
16
17
  resolveElementForOverlay,
17
18
  selectionCacheKey,
18
- toOverlayRect,
19
+ toVisibleOverlayRect,
19
20
  } from "./domEditOverlayGeometry";
20
21
 
21
22
  function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
@@ -164,7 +165,7 @@ export function useDomEditOverlayRects({
164
165
  for (let i = 0; i < descendants.length; i++) {
165
166
  const child = descendants[i] as HTMLElement;
166
167
  if (!child.getBoundingClientRect) continue;
167
- const r = toOverlayRect(overlayEl, iframe, child);
168
+ const r = toVisibleOverlayRect(overlayEl, iframe, child);
168
169
  if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
169
170
  }
170
171
  if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
@@ -203,7 +204,8 @@ export function useDomEditOverlayRects({
203
204
  if (liveGroupKeys.has(key)) continue;
204
205
  liveGroupKeys.add(key);
205
206
  const el = resolveGroupElement(doc, groupSelection);
206
- const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
207
+ const base = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
208
+ const rect = base && el ? { ...base, ...hugRectForElement(base, el) } : base;
207
209
  if (el && rect)
208
210
  nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
209
211
  }
@@ -33,8 +33,9 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
33
33
  });
34
34
  onNavigate(stack.length - 2);
35
35
  }}
36
- className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
37
- title="Back (Esc)"
36
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 active:scale-[0.98] transition-colors"
37
+ title="Back (Esc, or double-click empty timeline)"
38
+ aria-label="Back to parent composition"
38
39
  >
39
40
  <ArrowLeft size={12} weight="bold" />
40
41
  </button>
@@ -13,6 +13,7 @@ import type { TimelineElement } from "../../player";
13
13
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
14
14
  import { NLEPreview } from "./NLEPreview";
15
15
  import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
16
+ import { TimelineResizeDivider, MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
16
17
  import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
17
18
  import { useCompositionStack } from "./useCompositionStack";
18
19
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
@@ -23,6 +24,7 @@ import {
23
24
  getTimelineToggleTitle,
24
25
  } from "../../utils/timelineDiscovery";
25
26
  import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
27
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
26
28
 
27
29
  interface NLELayoutProps {
28
30
  projectId: string;
@@ -75,9 +77,7 @@ interface NLELayoutProps {
75
77
  onCompositionLoadingChange?: (loading: boolean) => void;
76
78
  }
77
79
 
78
- const MIN_TIMELINE_H = 100;
79
80
  const DEFAULT_TIMELINE_H = 220;
80
- const MIN_PREVIEW_H = 120;
81
81
 
82
82
  function subscribeFullscreen(cb: () => void) {
83
83
  document.addEventListener("fullscreenchange", cb);
@@ -138,13 +138,22 @@ export const NLELayout = memo(function NLELayout({
138
138
  stageRefForDrop.current = ref.current;
139
139
  }, []);
140
140
 
141
+ // Authored composition size measured from the loaded preview — drives drop
142
+ // coordinate mapping so blocks land where the user pointed on any comp size.
143
+ const [previewCompositionSize, setPreviewCompositionSize] = useState<{
144
+ width: number;
145
+ height: number;
146
+ } | null>(null);
147
+
141
148
  const {
142
149
  isDragOver: previewDragOver,
150
+ handleDragEnter: handlePreviewDragEnter,
143
151
  handleDragOver: handlePreviewDragOver,
144
152
  handleDragLeave: handlePreviewDragLeave,
145
153
  handleDrop: handlePreviewDrop,
146
154
  } = usePreviewBlockDrop({
147
155
  portrait,
156
+ compositionSize: previewCompositionSize,
148
157
  stageRef: stageRefForDrop as React.RefObject<HTMLDivElement | null>,
149
158
  onBlockDrop: onPreviewBlockDrop,
150
159
  });
@@ -289,7 +298,10 @@ export const NLELayout = memo(function NLELayout({
289
298
 
290
299
  useMountEffect(() => {
291
300
  fetch(`/api/projects/${projectId}/files/index.html`)
292
- .then((r) => r.json())
301
+ .then((r) => {
302
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
303
+ return r.json();
304
+ })
293
305
  .then((data: { content?: string }) => {
294
306
  const html = data.content || "";
295
307
  const map = new Map<string, string>();
@@ -307,7 +319,11 @@ export const NLELayout = memo(function NLELayout({
307
319
  setCompositionSourceMap(map);
308
320
  onCompIdToSrcChange?.(map);
309
321
  })
310
- .catch(() => {});
322
+ .catch((err: unknown) => {
323
+ // Non-fatal: drill-down still works via the iframe DOM scan; without
324
+ // the map only source-file resolution for sub-comps degrades.
325
+ console.warn("[studio] Couldn't load composition source map from index.html:", err);
326
+ });
311
327
  });
312
328
 
313
329
  // Patch elements with compositionSrc whenever elements or compIdToSrc change.
@@ -343,8 +359,24 @@ export const NLELayout = memo(function NLELayout({
343
359
  });
344
360
  }, [compIdToSrc]);
345
361
 
346
- // Resizable timeline height
347
- const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
362
+ // Resizable timeline height — persisted alongside zoom/pan so the user's
363
+ // workspace layout survives reloads.
364
+ const [timelineH, setTimelineH] = useState(() => {
365
+ const stored = readStudioUiPreferences().timelineHeight;
366
+ return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H;
367
+ });
368
+ const persistTimelineH = useCallback((height: number) => {
369
+ writeStudioUiPreferences({ timelineHeight: Math.round(height) });
370
+ }, []);
371
+ // A height persisted on a tall window can exceed this window's container and
372
+ // collapse the flex-1 preview to 0px — clamp once the container is measurable
373
+ // (the drag/keyboard paths already clamp; the restore path must too).
374
+ useEffect(() => {
375
+ const containerH = containerRef.current?.getBoundingClientRect().height;
376
+ if (!containerH) return;
377
+ const max = containerH - MIN_PREVIEW_H;
378
+ setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
379
+ }, []);
348
380
  const hasLoadedOnceRef = useRef(false);
349
381
  const [compositionLoading, setCompositionLoadingRaw] = useState(true);
350
382
  const setCompositionLoading = useCallback((loading: boolean) => {
@@ -360,7 +392,6 @@ export const NLELayout = memo(function NLELayout({
360
392
 
361
393
  const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement);
362
394
  const isTimelineVisible = timelineVisible ?? true;
363
- const isDragging = useRef(false);
364
395
  const containerRef = useRef<HTMLDivElement>(null);
365
396
  const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null;
366
397
 
@@ -382,37 +413,6 @@ export const NLELayout = memo(function NLELayout({
382
413
  onIframeRefStable.current?.(iframeRef.current);
383
414
  }, [compositionStack.length, refreshKey, iframeRef]);
384
415
 
385
- // Resize divider handlers
386
- const handleDividerPointerDown = useCallback(
387
- (e: React.PointerEvent) => {
388
- if (timelineDisabled) return;
389
- e.preventDefault();
390
- isDragging.current = true;
391
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
392
- },
393
- [timelineDisabled],
394
- );
395
-
396
- const handleDividerPointerMove = useCallback(
397
- (e: React.PointerEvent) => {
398
- if (timelineDisabled) return;
399
- if (!isDragging.current || !containerRef.current) return;
400
- const rect = containerRef.current.getBoundingClientRect();
401
- const mouseY = e.clientY - rect.top;
402
- const containerH = rect.height;
403
- const newTimelineH = Math.max(
404
- MIN_TIMELINE_H,
405
- Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
406
- );
407
- setTimelineH(newTimelineH);
408
- },
409
- [timelineDisabled],
410
- );
411
-
412
- const handleDividerPointerUp = useCallback(() => {
413
- isDragging.current = false;
414
- }, []);
415
-
416
416
  // Keyboard: Escape to pop composition level
417
417
  const handleKeyDown = useCallback(
418
418
  (e: React.KeyboardEvent) => {
@@ -451,6 +451,7 @@ export const NLELayout = memo(function NLELayout({
451
451
  e.clientY <= rect.bottom;
452
452
  if (!inside) onSelectTimelineElement?.(null);
453
453
  }}
454
+ onDragEnter={handlePreviewDragEnter}
454
455
  onDragOver={handlePreviewDragOver}
455
456
  onDragLeave={handlePreviewDragLeave}
456
457
  onDrop={handlePreviewDrop}
@@ -465,6 +466,7 @@ export const NLELayout = memo(function NLELayout({
465
466
  directUrl={directUrl}
466
467
  suppressLoadingOverlay={hasLoadedOnceRef.current}
467
468
  onStageRef={handleStageRef}
469
+ onCompositionSizeChange={setPreviewCompositionSize}
468
470
  />
469
471
  {previewDragOver && (
470
472
  <div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
@@ -491,16 +493,13 @@ export const NLELayout = memo(function NLELayout({
491
493
 
492
494
  {!isFullscreen && isTimelineVisible ? (
493
495
  <>
494
- {/* Resize divider */}
495
- <div
496
- className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10"
497
- style={{ touchAction: "none" }}
498
- onPointerDown={handleDividerPointerDown}
499
- onPointerMove={handleDividerPointerMove}
500
- onPointerUp={handleDividerPointerUp}
501
- >
502
- <div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
503
- </div>
496
+ <TimelineResizeDivider
497
+ timelineH={timelineH}
498
+ setTimelineH={setTimelineH}
499
+ persistTimelineH={persistTimelineH}
500
+ containerRef={containerRef}
501
+ disabled={timelineDisabled}
502
+ />
504
503
 
505
504
  {/* Timeline section */}
506
505
  <div
@@ -537,13 +536,17 @@ export const NLELayout = memo(function NLELayout({
537
536
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
538
537
  {timelineDisabled && (
539
538
  <div
540
- className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
539
+ className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
541
540
  data-testid="timeline-loading-disabled-overlay"
542
- aria-hidden="true"
541
+ role="status"
543
542
  onPointerDown={(event) => event.preventDefault()}
544
543
  onDragOver={(event) => event.preventDefault()}
545
544
  onDrop={(event) => event.preventDefault()}
546
- />
545
+ >
546
+ <span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
547
+ Loading composition…
548
+ </span>
549
+ </div>
547
550
  )}
548
551
  </div>
549
552
  </>
@@ -21,6 +21,8 @@ interface NLEPreviewProps {
21
21
  directUrl?: string;
22
22
  suppressLoadingOverlay?: boolean;
23
23
  onStageRef?: (ref: React.RefObject<HTMLDivElement | null>) => void;
24
+ /** Reports the authored composition size measured from the loaded preview. */
25
+ onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
24
26
  }
25
27
 
26
28
  export function getPreviewPlayerKey({
@@ -123,6 +125,7 @@ export const NLEPreview = memo(function NLEPreview({
123
125
  directUrl,
124
126
  suppressLoadingOverlay,
125
127
  onStageRef,
128
+ onCompositionSizeChange,
126
129
  }: NLEPreviewProps) {
127
130
  const activeKey = getPreviewPlayerKey({ projectId, directUrl });
128
131
  const viewportRef = useRef<HTMLDivElement>(null);
@@ -170,13 +173,22 @@ export const NLEPreview = memo(function NLEPreview({
170
173
  return () => observer.disconnect();
171
174
  }, [compositionSize, portrait]);
172
175
 
176
+ const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
177
+ onCompositionSizeChangeRef.current = onCompositionSizeChange;
178
+
173
179
  const updateCompositionSizeFromPreview = useCallback(() => {
174
180
  const next = readPreviewCompositionSize(previewIframeRef.current);
181
+ // Pure updater — the parent notification happens in the effect below
182
+ // (updaters may run more than once under Strict Mode / concurrent React).
175
183
  setCompositionSize((prev) =>
176
184
  prev?.width === next?.width && prev?.height === next?.height ? prev : next,
177
185
  );
178
186
  }, []);
179
187
 
188
+ useEffect(() => {
189
+ onCompositionSizeChangeRef.current?.(compositionSize);
190
+ }, [compositionSize]);
191
+
180
192
  const setPreviewIframeRef = useCallback(
181
193
  (node: HTMLIFrameElement | null) => {
182
194
  previewIframeRef.current = node;
@@ -206,10 +218,17 @@ export const NLEPreview = memo(function NLEPreview({
206
218
  };
207
219
  zoomRef.current = clamped;
208
220
 
209
- if (showHud && !zoomingRef.current) {
210
- zoomingRef.current = true;
221
+ if (showHud) {
211
222
  const hud = hudRef.current;
212
- if (hud) hud.style.opacity = "1";
223
+ if (hud) {
224
+ if (!zoomingRef.current) {
225
+ zoomingRef.current = true;
226
+ hud.style.opacity = "1";
227
+ }
228
+ // Live per-frame readout — without this the HUD shows an empty pill
229
+ // on the first-ever zoom and a stale percentage mid-gesture.
230
+ hud.textContent = isPreviewAtFit(clamped) ? "Fit" : `${Math.round(clamped.zoomPercent)}%`;
231
+ }
213
232
  }
214
233
 
215
234
  writeTransform(clamped);
@@ -254,7 +273,24 @@ export const NLEPreview = memo(function NLEPreview({
254
273
  const applyInitialZoom = useCallback(() => {
255
274
  const z = zoomRef.current;
256
275
  if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
257
- writeTransform(z);
276
+ // A pan persisted on a large window can restore the composition mostly
277
+ // off-screen in a smaller one; clamp against the current viewport first.
278
+ const viewport = viewportRef.current;
279
+ const rect = viewport?.getBoundingClientRect();
280
+ const sz = stageSizeRef.current;
281
+ if (rect && rect.width > 0 && rect.height > 0 && sz.width > 0 && sz.height > 0) {
282
+ const pan = clampPreviewPan({
283
+ panX: z.panX,
284
+ panY: z.panY,
285
+ zoomPercent: z.zoomPercent,
286
+ viewportWidth: rect.width,
287
+ viewportHeight: rect.height,
288
+ contentWidth: sz.width,
289
+ contentHeight: sz.height,
290
+ });
291
+ zoomRef.current = { ...z, ...pan };
292
+ }
293
+ writeTransform(zoomRef.current);
258
294
  }
259
295
  }, [writeTransform]);
260
296
 
@@ -474,7 +510,7 @@ export const NLEPreview = memo(function NLEPreview({
474
510
  <div
475
511
  ref={hudRef}
476
512
  className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-lg px-4 py-2 text-sm font-mono tabular-nums text-white/90 bg-black/60 backdrop-blur-sm shadow-lg"
477
- style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
513
+ style={{ opacity: 0, transition: "opacity 200ms ease-in" }}
478
514
  aria-live="polite"
479
515
  />
480
516
  {!isPreviewAtFit(settledZoom) && (
@@ -0,0 +1,97 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ export const MIN_TIMELINE_H = 100;
4
+ export const MIN_PREVIEW_H = 120;
5
+
6
+ /**
7
+ * Horizontal drag/keyboard-resizable divider between the preview and the
8
+ * timeline. Implements the separator pattern: ArrowUp grows the timeline,
9
+ * ArrowDown shrinks it (mirrors the drag direction).
10
+ */
11
+ export function TimelineResizeDivider({
12
+ timelineH,
13
+ setTimelineH,
14
+ persistTimelineH,
15
+ containerRef,
16
+ disabled,
17
+ }: {
18
+ timelineH: number;
19
+ setTimelineH: React.Dispatch<React.SetStateAction<number>>;
20
+ persistTimelineH: (h: number) => void;
21
+ containerRef: React.RefObject<HTMLDivElement | null>;
22
+ disabled: boolean;
23
+ }) {
24
+ const isDragging = useRef(false);
25
+ const timelineHRef = useRef(timelineH);
26
+ timelineHRef.current = timelineH;
27
+
28
+ const handlePointerDown = useCallback(
29
+ (e: React.PointerEvent) => {
30
+ if (disabled) return;
31
+ e.preventDefault();
32
+ isDragging.current = true;
33
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
34
+ },
35
+ [disabled],
36
+ );
37
+
38
+ const handlePointerMove = useCallback(
39
+ (e: React.PointerEvent) => {
40
+ if (disabled) return;
41
+ if (!isDragging.current || !containerRef.current) return;
42
+ const rect = containerRef.current.getBoundingClientRect();
43
+ const mouseY = e.clientY - rect.top;
44
+ const containerH = rect.height;
45
+ const newTimelineH = Math.max(
46
+ MIN_TIMELINE_H,
47
+ Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
48
+ );
49
+ setTimelineH(newTimelineH);
50
+ },
51
+ [disabled, containerRef, setTimelineH],
52
+ );
53
+
54
+ const handlePointerUp = useCallback(() => {
55
+ if (isDragging.current) persistTimelineH(timelineHRef.current);
56
+ isDragging.current = false;
57
+ }, [persistTimelineH]);
58
+
59
+ const handleKeyDown = useCallback(
60
+ (e: React.KeyboardEvent) => {
61
+ if (disabled) return;
62
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
63
+ e.preventDefault();
64
+ const containerH = containerRef.current?.getBoundingClientRect().height ?? Infinity;
65
+ const delta = e.key === "ArrowUp" ? 16 : -16;
66
+ setTimelineH((prev) => {
67
+ const next = Math.max(MIN_TIMELINE_H, Math.min(containerH - MIN_PREVIEW_H, prev + delta));
68
+ persistTimelineH(next);
69
+ return next;
70
+ });
71
+ },
72
+ [disabled, containerRef, setTimelineH, persistTimelineH],
73
+ );
74
+
75
+ return (
76
+ <div
77
+ role="separator"
78
+ aria-orientation="horizontal"
79
+ aria-label="Resize timeline (arrow keys)"
80
+ aria-valuenow={Math.round(timelineH)}
81
+ aria-valuemin={MIN_TIMELINE_H}
82
+ aria-valuemax={Math.round(
83
+ (containerRef.current?.getBoundingClientRect().height ?? 600) - MIN_PREVIEW_H,
84
+ )}
85
+ tabIndex={0}
86
+ className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10 outline-none focus-visible:bg-studio-accent/20"
87
+ style={{ touchAction: "none" }}
88
+ onPointerDown={handlePointerDown}
89
+ onPointerMove={handlePointerMove}
90
+ onPointerUp={handlePointerUp}
91
+ onPointerCancel={handlePointerUp}
92
+ onKeyDown={handleKeyDown}
93
+ >
94
+ <div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22 group-focus-visible:bg-studio-accent/60" />
95
+ </div>
96
+ );
97
+ }
@@ -1,8 +1,14 @@
1
- import { useCallback, useState, type RefObject } from "react";
1
+ import { useCallback, useRef, useState, type RefObject } from "react";
2
2
  import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
3
 
4
4
  interface UsePreviewBlockDropOptions {
5
5
  portrait?: boolean;
6
+ /**
7
+ * Authored composition size measured from the live preview. Preferred over
8
+ * the portrait fallback — hard-coding 1080/1920 places drops at the wrong
9
+ * spot for any composition authored at another size (square, 720p, 4K).
10
+ */
11
+ compositionSize?: { width: number; height: number } | null;
6
12
  stageRef: RefObject<HTMLDivElement | null>;
7
13
  onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void;
8
14
  }
@@ -28,6 +34,7 @@ function resolveCompositionPosition(
28
34
  clientX: number,
29
35
  clientY: number,
30
36
  stageRect: DOMRect,
37
+ compositionSize: { width: number; height: number } | null | undefined,
31
38
  portrait: boolean | undefined,
32
39
  ): { left: number; top: number } | null {
33
40
  if (stageRect.width === 0 || stageRect.height === 0) return null;
@@ -35,8 +42,8 @@ function resolveCompositionPosition(
35
42
  const normalizedX = (clientX - stageRect.left) / stageRect.width;
36
43
  const normalizedY = (clientY - stageRect.top) / stageRect.height;
37
44
 
38
- const compWidth = portrait ? 1080 : 1920;
39
- const compHeight = portrait ? 1920 : 1080;
45
+ const compWidth = compositionSize?.width ?? (portrait ? 1080 : 1920);
46
+ const compHeight = compositionSize?.height ?? (portrait ? 1920 : 1080);
40
47
 
41
48
  return {
42
49
  left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)),
@@ -58,10 +65,24 @@ function centerBlockAtPosition(
58
65
 
59
66
  export function usePreviewBlockDrop({
60
67
  portrait,
68
+ compositionSize,
61
69
  stageRef,
62
70
  onBlockDrop,
63
71
  }: UsePreviewBlockDropOptions) {
64
72
  const [isDragOver, setIsDragOver] = useState(false);
73
+ // dragenter/dragleave fire for every internal element boundary; a depth
74
+ // counter keeps the drop indicator steady instead of flickering.
75
+ const dragDepthRef = useRef(0);
76
+
77
+ const handleDragEnter = useCallback(
78
+ (e: React.DragEvent) => {
79
+ if (!onBlockDrop) return;
80
+ if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
81
+ dragDepthRef.current += 1;
82
+ setIsDragOver(true);
83
+ },
84
+ [onBlockDrop],
85
+ );
65
86
 
66
87
  const handleDragOver = useCallback(
67
88
  (e: React.DragEvent) => {
@@ -69,18 +90,20 @@ export function usePreviewBlockDrop({
69
90
  if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
70
91
  e.preventDefault();
71
92
  e.dataTransfer.dropEffect = "copy";
72
- setIsDragOver(true);
93
+ // dragenter/dragleave own the isDragOver flag (depth-counted).
73
94
  },
74
95
  [onBlockDrop],
75
96
  );
76
97
 
77
98
  const handleDragLeave = useCallback(() => {
78
- setIsDragOver(false);
99
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
100
+ if (dragDepthRef.current === 0) setIsDragOver(false);
79
101
  }, []);
80
102
 
81
103
  // fallow-ignore-next-line complexity
82
104
  const handleDrop = useCallback(
83
105
  (e: React.DragEvent) => {
106
+ dragDepthRef.current = 0;
84
107
  setIsDragOver(false);
85
108
  if (!onBlockDrop) return;
86
109
 
@@ -96,14 +119,15 @@ export function usePreviewBlockDrop({
96
119
  e.clientX,
97
120
  e.clientY,
98
121
  stage.getBoundingClientRect(),
122
+ compositionSize,
99
123
  portrait,
100
124
  );
101
125
  if (!pos) return;
102
126
 
103
127
  onBlockDrop(block.name, centerBlockAtPosition(pos, block));
104
128
  },
105
- [onBlockDrop, stageRef, portrait],
129
+ [onBlockDrop, stageRef, compositionSize, portrait],
106
130
  );
107
131
 
108
- return { isDragOver, handleDragOver, handleDragLeave, handleDrop };
132
+ return { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop };
109
133
  }