@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
@@ -5,10 +5,19 @@ import { useState, useEffect } from "react";
5
5
  * video + canvas. Seeks to ~10% of duration to avoid black opening frames.
6
6
  * Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
7
7
  */
8
- export function VideoFrameThumbnail({ src }: { src: string }) {
8
+ export function VideoFrameThumbnail({
9
+ src,
10
+ fallbackLabel,
11
+ }: {
12
+ src: string;
13
+ /** Shown instead of an endless shimmer when the video can't be decoded. */
14
+ fallbackLabel?: string;
15
+ }) {
9
16
  const [frame, setFrame] = useState<string | null>(null);
17
+ const [failed, setFailed] = useState(false);
10
18
 
11
19
  useEffect(() => {
20
+ setFailed(false);
12
21
  const video = document.createElement("video");
13
22
  video.crossOrigin = "anonymous";
14
23
  video.muted = true;
@@ -35,15 +44,29 @@ export function VideoFrameThumbnail({ src }: { src: string }) {
35
44
  cleanup();
36
45
  });
37
46
 
38
- video.addEventListener("error", cleanup);
47
+ video.addEventListener("error", () => {
48
+ // Resolve the loading state — a permanent shimmer reads as "still loading".
49
+ setFailed(true);
50
+ cleanup();
51
+ });
39
52
  video.src = src;
40
53
  video.load();
41
54
 
42
55
  return cleanup;
43
56
  }, [src]);
44
57
 
58
+ if (failed && !frame) {
59
+ return (
60
+ <div className="w-full h-full bg-neutral-800 flex items-center justify-center">
61
+ <span className="text-[9px] font-medium text-neutral-600">{fallbackLabel ?? "VIDEO"}</span>
62
+ </div>
63
+ );
64
+ }
65
+
45
66
  if (!frame) {
46
- return <div className="w-full h-full bg-neutral-800 animate-pulse" />;
67
+ return (
68
+ <div className="w-full h-full bg-neutral-800 animate-pulse motion-reduce:animate-none" />
69
+ );
47
70
  }
48
71
 
49
72
  return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
@@ -0,0 +1,83 @@
1
+ // fallow-ignore-file unused-file
2
+ // (consumers land in the shell/sidebar PRs later in this stack)
3
+ import { useEffect, useCallback, useRef, type RefObject } from "react";
4
+
5
+ const FOCUSABLE =
6
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
7
+
8
+ interface DialogBehaviorOptions {
9
+ open: boolean;
10
+ onClose: () => void;
11
+ containerRef: RefObject<HTMLElement | null>;
12
+ /**
13
+ * Return false to veto a close triggered by Escape/backdrop (e.g. a dirty
14
+ * draft the user hasn't submitted). Direct onClose calls are not guarded.
15
+ */
16
+ canClose?: () => boolean;
17
+ }
18
+
19
+ /**
20
+ * Shared dialog contract for the studio's custom modals: document-level
21
+ * Escape, Tab focus trap, focus-first-control on open, focus restore on close.
22
+ * The consumer still renders its own markup and should set role="dialog" and
23
+ * aria-modal="true" on the container.
24
+ */
25
+ export function useDialogBehavior({
26
+ open,
27
+ onClose,
28
+ containerRef,
29
+ canClose,
30
+ }: DialogBehaviorOptions) {
31
+ const restoreRef = useRef<HTMLElement | null>(null);
32
+ const canCloseRef = useRef(canClose);
33
+ canCloseRef.current = canClose;
34
+ const onCloseRef = useRef(onClose);
35
+ onCloseRef.current = onClose;
36
+
37
+ const requestClose = useCallback(() => {
38
+ const guard = canCloseRef.current;
39
+ if (guard && !guard()) return;
40
+ onCloseRef.current();
41
+ }, []);
42
+
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ const previouslyFocused = document.activeElement;
46
+ restoreRef.current = previouslyFocused instanceof HTMLElement ? previouslyFocused : null;
47
+
48
+ const container = containerRef.current;
49
+ const first = container?.querySelector<HTMLElement>(FOCUSABLE);
50
+ (first ?? container)?.focus();
51
+
52
+ const onKeyDown = (e: KeyboardEvent) => {
53
+ if (e.key === "Escape") {
54
+ e.stopPropagation();
55
+ requestClose();
56
+ return;
57
+ }
58
+ if (e.key !== "Tab") return;
59
+ const el = containerRef.current;
60
+ if (!el) return;
61
+ const focusables = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE));
62
+ if (focusables.length === 0) return;
63
+ const firstEl = focusables[0];
64
+ const lastEl = focusables[focusables.length - 1];
65
+ const active = document.activeElement;
66
+ if (e.shiftKey && (active === firstEl || !el.contains(active))) {
67
+ e.preventDefault();
68
+ lastEl.focus();
69
+ } else if (!e.shiftKey && (active === lastEl || !el.contains(active))) {
70
+ e.preventDefault();
71
+ firstEl.focus();
72
+ }
73
+ };
74
+ document.addEventListener("keydown", onKeyDown, true);
75
+ return () => {
76
+ document.removeEventListener("keydown", onKeyDown, true);
77
+ restoreRef.current?.focus();
78
+ restoreRef.current = null;
79
+ };
80
+ }, [open, containerRef, requestClose]);
81
+
82
+ return { requestClose };
83
+ }
@@ -16,6 +16,7 @@ export function PanelLayoutProvider({
16
16
  leftWidth,
17
17
  setLeftWidth,
18
18
  rightWidth,
19
+ setRightWidth,
19
20
  leftCollapsed,
20
21
  setLeftCollapsed,
21
22
  rightCollapsed,
@@ -39,6 +40,7 @@ export function PanelLayoutProvider({
39
40
  leftWidth,
40
41
  setLeftWidth,
41
42
  rightWidth,
43
+ setRightWidth,
42
44
  leftCollapsed,
43
45
  setLeftCollapsed,
44
46
  rightCollapsed,
@@ -56,6 +58,7 @@ export function PanelLayoutProvider({
56
58
  leftWidth,
57
59
  setLeftWidth,
58
60
  rightWidth,
61
+ setRightWidth,
59
62
  leftCollapsed,
60
63
  setLeftCollapsed,
61
64
  rightCollapsed,
@@ -19,7 +19,12 @@ export interface StudioShellValue {
19
19
  renderQueue: {
20
20
  jobs: unknown[];
21
21
  isRendering: boolean;
22
+ loadError: string | null;
23
+ actionError: string | null;
24
+ dismissActionError: () => void;
25
+ reloadRenders: () => void;
22
26
  deleteRender: (jobId: string) => void;
27
+ cancelRender: (jobId: string) => void;
23
28
  clearCompleted: () => void;
24
29
  startRender: (options: unknown) => Promise<void>;
25
30
  };
@@ -58,6 +63,7 @@ export function useStudioPlaybackContext(): StudioPlaybackValue {
58
63
  }
59
64
 
60
65
  /** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */
66
+ // fallow-ignore-next-line unused-export
61
67
  export function useStudioContext(): StudioContextValue {
62
68
  const shell = useStudioShellContext();
63
69
  const playback = useStudioPlaybackContext();
@@ -166,6 +172,7 @@ export function StudioPlaybackProvider({
166
172
  }
167
173
 
168
174
  /** @deprecated Use StudioShellProvider and StudioPlaybackProvider instead. */
175
+ // fallow-ignore-next-line unused-export
169
176
  export function StudioProvider({
170
177
  value,
171
178
  children,
@@ -30,6 +30,7 @@ export function makeSelection(label: string, element: HTMLElement): DomEditSelec
30
30
  capabilities: {
31
31
  canSelect: true,
32
32
  canEditStyles: true,
33
+ canCrop: true,
33
34
  canMove: true,
34
35
  canResize: true,
35
36
  canApplyManualOffset: true,
@@ -0,0 +1,40 @@
1
+ import { useCallback } from "react";
2
+
3
+ /**
4
+ * Loads a composition file's content for the source editor when a composition
5
+ * is selected. Content stays null until the fetch resolves — the source editor
6
+ * must not mount on a null-content file, or its autosave would overwrite the
7
+ * real file with an empty document. Load failures surface as an error toast
8
+ * instead of silently rendering an empty (and autosave-armed) editor.
9
+ */
10
+ export function useCompositionContentLoader({
11
+ projectId,
12
+ setEditingFile,
13
+ setActiveCompPath,
14
+ showToast,
15
+ }: {
16
+ projectId: string | null;
17
+ setEditingFile: (file: { path: string; content: string | null }) => void;
18
+ setActiveCompPath: (path: string | null) => void;
19
+ showToast: (message: string, tone?: "error" | "info") => void;
20
+ }) {
21
+ return useCallback(
22
+ (comp: string) => {
23
+ setActiveCompPath(comp.endsWith(".html") ? comp : null);
24
+ setEditingFile({ path: comp, content: null });
25
+ fetch(`/api/projects/${projectId}/files/${comp}`)
26
+ .then(async (r) => {
27
+ if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
28
+ return r.json();
29
+ })
30
+ .then((data: { content?: string }) => {
31
+ if (typeof data.content !== "string") throw new Error(`No content returned for ${comp}`);
32
+ setEditingFile({ path: comp, content: data.content });
33
+ })
34
+ .catch((err) => {
35
+ showToast(err instanceof Error ? err.message : `Failed to load ${comp}`, "error");
36
+ });
37
+ },
38
+ [projectId, setEditingFile, setActiveCompPath, showToast],
39
+ );
40
+ }
@@ -0,0 +1,91 @@
1
+ import { useEffect, useMemo, useReducer } from "react";
2
+ import { usePlayerStore } from "../player";
3
+
4
+ export interface CropModeProps {
5
+ cropMode: boolean;
6
+ onCropModeChange: (active: boolean) => void;
7
+ }
8
+
9
+ /** Crop mode lives in the player store so the canvas toolbar, the Clip panel,
10
+ * and the overlay all share one switch without prop threading. */
11
+ export function useCropModeProps(): CropModeProps {
12
+ const cropMode = usePlayerStore((s) => s.cropMode);
13
+ const setCropMode = usePlayerStore((s) => s.setCropMode);
14
+ return useMemo(
15
+ () => ({
16
+ cropMode,
17
+ onCropModeChange: setCropMode,
18
+ }),
19
+ [cropMode, setCropMode],
20
+ );
21
+ }
22
+
23
+ import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
24
+ import type { DomEditSelection } from "../components/editor/domEditing";
25
+ import { readElementCropInsets } from "../components/editor/domEditOverlayCrop";
26
+
27
+ /** Overlay-side crop state: Escape-to-exit, toolbar availability publishing,
28
+ * and the box clip that makes the selection outline hug the cropped region.
29
+ * The box div itself always sits at the FULL element bounds — gestures write
30
+ * its position directly during drags, so moving/resizing it in React would
31
+ * fight them. The hug is purely visual: the element's inset clip-path scaled
32
+ * into overlay space and applied to the box. */
33
+ export function useCropOverlay(params: {
34
+ selection: DomEditSelection | null;
35
+ groupCount: number;
36
+ cropMode: boolean;
37
+ onCropModeChange?: (active: boolean) => void;
38
+ overlayRect: OverlayRect | null;
39
+ }) {
40
+ const { selection, groupCount, cropMode, onCropModeChange, overlayRect } = params;
41
+
42
+ useEffect(() => {
43
+ if (!cropMode || !onCropModeChange) return;
44
+ const handleKeyDown = (event: KeyboardEvent) => {
45
+ if (event.key === "Escape") onCropModeChange(false);
46
+ };
47
+ window.addEventListener("keydown", handleKeyDown);
48
+ return () => window.removeEventListener("keydown", handleKeyDown);
49
+ }, [cropMode, onCropModeChange]);
50
+
51
+ // Publish availability so the canvas toolbar shows the Crop button only
52
+ // when the selection can take a clip-path crop.
53
+ const setCropAvailable = usePlayerStore((s) => s.setCropAvailable);
54
+ const cropAvailable = Boolean(selection && groupCount <= 1 && selection.capabilities.canCrop);
55
+ useEffect(() => {
56
+ setCropAvailable(cropAvailable);
57
+ return () => setCropAvailable(false);
58
+ }, [cropAvailable, setCropAvailable]);
59
+
60
+ // Crop-mode exit restores the element's clip in an effect cleanup — after
61
+ // this hook already read it. One forced re-render picks up the fresh insets
62
+ // so the selection box hugs the crop immediately.
63
+ const [, bumpAfterExit] = useReducer((x: number) => x + 1, 0);
64
+ useEffect(() => {
65
+ if (!cropMode) bumpAfterExit();
66
+ }, [cropMode]);
67
+
68
+ const cropInsets = selection ? readElementCropInsets(selection.element) : null;
69
+ const hasCropInsets = Boolean(
70
+ cropInsets &&
71
+ (cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
72
+ );
73
+
74
+ // Scaled insets for the crop outline child + the resize-handle shift. The
75
+ // box div itself stays border-less at full bounds; a child draws the
76
+ // outline ON the crop boundary (a clip on the box would swallow the
77
+ // border everywhere the crop edge doesn't touch the element edge).
78
+ const sx = overlayRect && overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
79
+ const sy = overlayRect && overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
80
+ const cropOutlineInsetPx =
81
+ cropInsets && hasCropInsets && !cropMode
82
+ ? {
83
+ top: cropInsets.top * sy,
84
+ right: cropInsets.right * sx,
85
+ bottom: cropInsets.bottom * sy,
86
+ left: cropInsets.left * sx,
87
+ }
88
+ : undefined;
89
+
90
+ return { hasCropInsets, cropOutlineInsetPx };
91
+ }
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
  import { act, createElement } from "react";
3
4
  import { createRoot, type Root } from "react-dom/client";
@@ -183,6 +184,7 @@ function createSelection(
183
184
  capabilities: {
184
185
  canSelect: true,
185
186
  canEditStyles: true,
187
+ canCrop: true,
186
188
  canMove: true,
187
189
  canResize: true,
188
190
  canApplyManualOffset: true,
@@ -18,6 +18,7 @@ interface UseEditorSaveOptions {
18
18
  recordEdit: (input: RecordEditInput) => Promise<void>;
19
19
  domEditSaveTimestampRef: React.MutableRefObject<number>;
20
20
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
21
+ showToast: (message: string, tone?: "error" | "info") => void;
21
22
  }
22
23
 
23
24
  export function useEditorSave({
@@ -28,9 +29,13 @@ export function useEditorSave({
28
29
  recordEdit,
29
30
  domEditSaveTimestampRef,
30
31
  setRefreshKey,
32
+ showToast,
31
33
  }: UseEditorSaveOptions) {
32
34
  const saveRafRef = useRef<number | null>(null);
33
35
  const refreshRafRef = useRef<number | null>(null);
36
+ // One error toast per burst of failures — every keystroke retries the save,
37
+ // and error toasts persist until dismissed, so don't stack duplicates.
38
+ const lastFailureToastAtRef = useRef(0);
34
39
 
35
40
  const handleContentChange = useCallback(
36
41
  (content: string) => {
@@ -61,6 +66,14 @@ export function useEditorSave({
61
66
  source: "code_editor",
62
67
  error_message: error instanceof Error ? error.message : "unknown",
63
68
  });
69
+ const now = Date.now();
70
+ if (now - lastFailureToastAtRef.current > 5000) {
71
+ lastFailureToastAtRef.current = now;
72
+ showToast(
73
+ `Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
74
+ "error",
75
+ );
76
+ }
64
77
  });
65
78
  });
66
79
  },
@@ -71,6 +84,7 @@ export function useEditorSave({
71
84
  readProjectFile,
72
85
  recordEdit,
73
86
  setRefreshKey,
87
+ showToast,
74
88
  writeProjectFile,
75
89
  ],
76
90
  );
@@ -126,6 +126,7 @@ export function useFileManager({
126
126
  recordEdit,
127
127
  domEditSaveTimestampRef,
128
128
  setRefreshKey,
129
+ showToast,
129
130
  });
130
131
 
131
132
  // ── File select ──
@@ -133,26 +134,34 @@ export function useFileManager({
133
134
  const revealRequestIdRef = useRef(0);
134
135
  const revealAbortRef = useRef<AbortController | null>(null);
135
136
 
136
- const handleFileSelect = useCallback((path: string) => {
137
- const pid = projectIdRef.current;
138
- if (!pid) return;
139
- revealAbortRef.current?.abort();
140
- revealAbortRef.current = null;
141
- revealRequestIdRef.current++;
142
- // Skip fetching binary content for media files — just set the path for preview
143
- if (isMediaFile(path)) {
144
- setEditingFile({ path, content: null });
145
- return;
146
- }
147
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
148
- .then((r) => r.json())
149
- .then((data: { content?: string }) => {
150
- if (data.content != null) {
151
- setEditingFile({ path, content: data.content });
152
- }
153
- })
154
- .catch(() => {});
155
- }, []);
137
+ const handleFileSelect = useCallback(
138
+ (path: string) => {
139
+ const pid = projectIdRef.current;
140
+ if (!pid) return;
141
+ revealAbortRef.current?.abort();
142
+ revealAbortRef.current = null;
143
+ revealRequestIdRef.current++;
144
+ // Skip fetching binary content for media files — just set the path for preview
145
+ if (isMediaFile(path)) {
146
+ setEditingFile({ path, content: null });
147
+ return;
148
+ }
149
+ fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
150
+ .then((r) => {
151
+ if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
152
+ return r.json();
153
+ })
154
+ .then((data: { content?: string }) => {
155
+ if (data.content != null) {
156
+ setEditingFile({ path, content: data.content });
157
+ }
158
+ })
159
+ .catch((err: unknown) => {
160
+ showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
161
+ });
162
+ },
163
+ [showToast],
164
+ );
156
165
 
157
166
  // ── Click-to-source ──
158
167
 
@@ -253,9 +262,10 @@ export function useFileManager({
253
262
  } else {
254
263
  const err = await res.json().catch(() => ({ error: "unknown" }));
255
264
  console.error(`Create file failed: ${err.error}`);
265
+ showToast(`Couldn't create ${path}: ${err.error}`, "error");
256
266
  }
257
267
  },
258
- [refreshFileTree, handleFileSelect],
268
+ [refreshFileTree, handleFileSelect, showToast],
259
269
  );
260
270
 
261
271
  const handleCreateFolder = useCallback(
@@ -275,9 +285,10 @@ export function useFileManager({
275
285
  } else {
276
286
  const err = await res.json().catch(() => ({ error: "unknown" }));
277
287
  console.error(`Create folder failed: ${err.error}`);
288
+ showToast(`Couldn't create folder ${path}: ${err.error}`, "error");
278
289
  }
279
290
  },
280
- [refreshFileTree],
291
+ [refreshFileTree, showToast],
281
292
  );
282
293
 
283
294
  const handleDeleteFile = useCallback(
@@ -293,9 +304,10 @@ export function useFileManager({
293
304
  } else {
294
305
  const err = await res.json().catch(() => ({ error: "unknown" }));
295
306
  console.error(`Delete failed: ${err.error}`);
307
+ showToast(`Couldn't delete ${path}: ${err.error}`, "error");
296
308
  }
297
309
  },
298
- [refreshFileTree],
310
+ [refreshFileTree, showToast],
299
311
  );
300
312
 
301
313
  const handleRenameFile = useCallback(
@@ -316,9 +328,10 @@ export function useFileManager({
316
328
  } else {
317
329
  const err = await res.json().catch(() => ({ error: "unknown" }));
318
330
  console.error(`Rename failed: ${err.error}`);
331
+ showToast(`Couldn't rename ${oldPath}: ${err.error}`, "error");
319
332
  }
320
333
  },
321
- [refreshFileTree, handleFileSelect, setRefreshKey],
334
+ [refreshFileTree, handleFileSelect, setRefreshKey, showToast],
322
335
  );
323
336
 
324
337
  const handleDuplicateFile = useCallback(
@@ -337,9 +350,10 @@ export function useFileManager({
337
350
  } else {
338
351
  const err = await res.json().catch(() => ({ error: "unknown" }));
339
352
  console.error(`Duplicate failed: ${err.error}`);
353
+ showToast(`Couldn't duplicate ${path}: ${err.error}`, "error");
340
354
  }
341
355
  },
342
- [refreshFileTree, handleFileSelect],
356
+ [refreshFileTree, handleFileSelect, showToast],
343
357
  );
344
358
 
345
359
  const handleMoveFile = handleRenameFile;
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, type MouseEvent } from "react";
1
+ import { useState, useCallback, useRef, type MouseEvent } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import { liveTime, usePlayerStore } from "../player";
4
4
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
@@ -17,6 +17,8 @@ export function useFrameCapture({
17
17
  waitForPendingDomEditSaves,
18
18
  }: UseFrameCaptureParams) {
19
19
  const [captureFrameTime, setCaptureFrameTime] = useState(0);
20
+ const [capturing, setCapturing] = useState(false);
21
+ const capturingRef = useRef(false);
20
22
 
21
23
  useMountEffect(() => {
22
24
  setCaptureFrameTime(usePlayerStore.getState().currentTime);
@@ -31,6 +33,11 @@ export function useFrameCapture({
31
33
  async (event: MouseEvent<HTMLAnchorElement>) => {
32
34
  if (!projectId) return;
33
35
  event.preventDefault();
36
+ // A capture can take up to ~35s (save drain + server render) — ignore
37
+ // re-entrant clicks instead of firing parallel captures.
38
+ if (capturingRef.current) return;
39
+ capturingRef.current = true;
40
+ setCapturing(true);
34
41
  try {
35
42
  const time = usePlayerStore.getState().currentTime;
36
43
  setCaptureFrameTime(time);
@@ -79,6 +86,9 @@ export function useFrameCapture({
79
86
  }
80
87
  } catch (err) {
81
88
  showToast(err instanceof Error ? err.message : "Capture failed", "error");
89
+ } finally {
90
+ capturingRef.current = false;
91
+ setCapturing(false);
82
92
  }
83
93
  },
84
94
  [activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
@@ -98,5 +108,6 @@ export function useFrameCapture({
98
108
  captureFrameFilename,
99
109
  handleCaptureFrameClick,
100
110
  refreshCaptureFrameTime,
111
+ capturing,
101
112
  };
102
113
  }
@@ -100,6 +100,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
100
100
  leftWidth,
101
101
  setLeftWidth,
102
102
  rightWidth,
103
+ setRightWidth,
103
104
  leftCollapsed,
104
105
  setLeftCollapsed,
105
106
  rightCollapsed,
@@ -8,6 +8,7 @@ import {
8
8
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
9
9
  import type { EditHistoryKind } from "../utils/editHistory";
10
10
  import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
11
+ import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
11
12
  import { trackStudioEvent } from "../utils/studioTelemetry";
12
13
 
13
14
  // ── Types ──
@@ -77,6 +78,24 @@ function shouldReloadForStudioFileChange(
77
78
  return Date.now() - domEditSaveTimestampRef.current >= 4000;
78
79
  }
79
80
 
81
+ // fallow-ignore-next-line complexity
82
+ async function clearLegacyStudioMotionFile(
83
+ readOptionalProjectFile: (path: string) => Promise<string>,
84
+ writeProjectFile: (path: string, content: string) => Promise<void>,
85
+ ): Promise<void> {
86
+ const content = await readOptionalProjectFile(STUDIO_MOTION_PATH).catch(() => null);
87
+ if (!content) return;
88
+ try {
89
+ const parsed = JSON.parse(content) as { motions?: unknown[] };
90
+ if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
91
+ } catch {
92
+ return;
93
+ }
94
+ await writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] })).catch(
95
+ () => {},
96
+ );
97
+ }
98
+
80
99
  // ── Hook ──
81
100
 
82
101
  export function usePreviewPersistence({
@@ -135,6 +154,7 @@ export function usePreviewPersistence({
135
154
  }, []);
136
155
 
137
156
  const waitForPendingDomEditSaves = useCallback(async () => {
157
+ await flushStudioPendingEdits();
138
158
  await domEditSaveQueueRef.current?.waitForIdle();
139
159
  }, []);
140
160
 
@@ -185,20 +205,7 @@ export function usePreviewPersistence({
185
205
  // could still fire alongside the new seek-reapply runtime. Empty the file so
186
206
  // the legacy codepath no-ops.
187
207
  useMountEffect(() => {
188
- _readOptionalProjectFile(STUDIO_MOTION_PATH)
189
- .then((content) => {
190
- if (!content) return;
191
- try {
192
- const parsed = JSON.parse(content) as { motions?: unknown[] };
193
- if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
194
- } catch {
195
- return;
196
- }
197
- return _writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] }));
198
- })
199
- .catch(() => {
200
- /* best-effort migration — ignore failures */
201
- });
208
+ void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
202
209
  });
203
210
 
204
211
  // ── Listen for external file changes (HMR / SSE) ──
@@ -210,8 +217,10 @@ export function usePreviewPersistence({
210
217
  pendingTimelineEditPathRef,
211
218
  domEditSaveTimestampRef,
212
219
  )
213
- )
220
+ ) {
221
+ // fallow-ignore-next-line code-duplication
214
222
  reloadPreview();
223
+ }
215
224
  };
216
225
  if (import.meta.hot) {
217
226
  import.meta.hot.on("hf:file-change", handler);
@@ -21,7 +21,12 @@ interface StudioContextInput {
21
21
  renderQueue: {
22
22
  jobs: unknown[];
23
23
  isRendering: boolean;
24
+ loadError: string | null;
25
+ actionError: string | null;
26
+ dismissActionError: () => void;
27
+ reloadRenders: () => void;
24
28
  deleteRender: (id: string) => void;
29
+ cancelRender: (id: string) => void;
25
30
  clearCompleted: () => void;
26
31
  startRender: (options: unknown) => Promise<void>;
27
32
  };