@hyperframes/studio 0.8.49 → 0.8.50

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 (95) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
  2. package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +12 -4
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5899 -5754
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +10 -8
  12. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  13. package/src/components/EditorShell.tsx +24 -0
  14. package/src/components/StudioOverlays.tsx +1 -5
  15. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  16. package/src/components/editor/PreviewGuides.tsx +125 -0
  17. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  18. package/src/components/editor/SnapToolbar.tsx +25 -1
  19. package/src/components/editor/domEditingAgentPrompt.ts +5 -0
  20. package/src/components/editor/previewGuidesStore.ts +26 -0
  21. package/src/components/nle/NLEPreview.test.ts +13 -0
  22. package/src/components/nle/NLEPreview.tsx +9 -4
  23. package/src/components/nle/PreviewPane.test.tsx +52 -0
  24. package/src/components/nle/PreviewPane.tsx +2 -0
  25. package/src/components/nle/TimelinePane.tsx +14 -0
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  28. package/src/hooks/appHotkeysDispatch.ts +11 -0
  29. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  30. package/src/hooks/timelineTrackVisibility.ts +8 -11
  31. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  32. package/src/hooks/useAppHotkeys.ts +3 -0
  33. package/src/hooks/useAskAgentModal.ts +2 -0
  34. package/src/hooks/useClipboard.test.ts +234 -0
  35. package/src/hooks/useClipboard.ts +264 -70
  36. package/src/hooks/useGroupCommits.test.ts +0 -1
  37. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  38. package/src/hooks/useRazorSplit.test.ts +0 -2
  39. package/src/hooks/useRazorSplit.test.tsx +0 -1
  40. package/src/hooks/useStudioContextValue.ts +5 -19
  41. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  42. package/src/player/components/ClipContextMenu.tsx +87 -7
  43. package/src/player/components/Timeline.tsx +14 -14
  44. package/src/player/components/TimelineCanvas.tsx +30 -0
  45. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  46. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  47. package/src/player/components/TimelineLanes.test.tsx +22 -1
  48. package/src/player/components/TimelineOverlays.tsx +12 -0
  49. package/src/player/components/TimelineTypes.ts +4 -0
  50. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  51. package/src/player/components/timelineClipChildren.test.ts +2 -2
  52. package/src/player/components/timelineClipChildren.tsx +2 -1
  53. package/src/player/components/timelineClipDragCommit.ts +2 -2
  54. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  55. package/src/player/components/timelineDragDrop.ts +30 -23
  56. package/src/player/components/timelineEditCapabilities.ts +1 -2
  57. package/src/player/components/timelineEditing.test.ts +0 -16
  58. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  59. package/src/player/components/timelineGroupEditing.ts +0 -1
  60. package/src/player/components/timelineTrackDisplay.ts +1 -1
  61. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  62. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  63. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  64. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  65. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  66. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  67. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  68. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  69. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  70. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  71. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  72. package/src/player/lib/describeClips.test.ts +95 -0
  73. package/src/player/lib/describeClips.ts +103 -0
  74. package/src/player/lib/timelineDOM.test.ts +0 -80
  75. package/src/player/lib/timelineDOM.ts +1 -61
  76. package/src/player/lib/timelineElementHelpers.ts +2 -53
  77. package/src/player/store/playerStore.ts +5 -0
  78. package/src/player/store/timelineElement.ts +1 -3
  79. package/src/shims/canvasBrowserStub.js +50 -0
  80. package/src/utils/clipboardPayload.test.ts +42 -2
  81. package/src/utils/clipboardPayload.ts +57 -32
  82. package/src/utils/previewSafeMargins.test.ts +34 -0
  83. package/src/utils/previewSafeMargins.ts +10 -0
  84. package/src/utils/razorSplitTransaction.test.ts +0 -1
  85. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  86. package/src/utils/studioUiPreferences.test.ts +43 -0
  87. package/src/utils/studioUiPreferences.ts +27 -6
  88. package/src/utils/timelineElementSplit.test.ts +1 -2
  89. package/src/utils/timelineElementSplit.ts +0 -1
  90. package/src/webmcp/tools/lookTools.test.ts +40 -0
  91. package/src/webmcp/tools/lookTools.ts +32 -1
  92. package/dist/assets/index-B9LfWNyM.css +0 -1
  93. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  94. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  95. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.49",
3
+ "version": "0.8.50",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/parsers": "0.8.49",
52
- "@hyperframes/studio-server": "0.8.49",
53
- "@hyperframes/player": "0.8.49",
54
- "@hyperframes/sdk": "0.8.49",
55
- "@hyperframes/core": "0.8.49"
51
+ "@hyperframes/player": "0.8.50",
52
+ "@hyperframes/core": "0.8.50",
53
+ "@hyperframes/studio-server": "0.8.50",
54
+ "@hyperframes/sdk": "0.8.50",
55
+ "@hyperframes/parsers": "0.8.50"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^4.1.11",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.49"
72
+ "@hyperframes/producer": "0.8.50"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
package/src/App.tsx CHANGED
@@ -211,7 +211,7 @@ export function StudioApp() {
211
211
  const domEditDeleteBridge: DomEditDelete = (s, o) => handleDomEditElementDeleteRef.current(s, o);
212
212
  const resetKeyframesRef = useRef<() => boolean>(() => false);
213
213
  const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
214
- const { handleCopy, handlePaste, handleCut } = useClipboard({
214
+ const { handleCopy, handlePaste, handleCut, handleDuplicate, canPaste } = useClipboard({
215
215
  projectId,
216
216
  activeCompPath,
217
217
  domEditSelectionRef: domEditSelectionBridgeRef,
@@ -219,7 +219,7 @@ export function StudioApp() {
219
219
  writeProjectFile: fileManager.writeProjectFile,
220
220
  recordEdit: editHistory.recordEdit,
221
221
  reloadPreview,
222
- handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
222
+ handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
223
223
  handleDomEditElementDelete: domEditDeleteBridge,
224
224
  previewIframeRef,
225
225
  });
@@ -240,6 +240,7 @@ export function StudioApp() {
240
240
  handleCopy,
241
241
  handlePaste,
242
242
  handleCut,
243
+ handleDuplicate,
243
244
  onResetKeyframes: () => resetKeyframesRef.current(),
244
245
  onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
245
246
  onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
@@ -341,7 +342,7 @@ export function StudioApp() {
341
342
  setConsoleErrors,
342
343
  resetErrors: resetConsoleErrors,
343
344
  } = useConsoleErrorCapture(previewIframe);
344
- const dragOverlay = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
345
+ const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
345
346
  const handleToggleRecordingRef = useRef<() => void>(() => {});
346
347
  const domEditSessionRef = useRef(domEditSession);
347
348
  domEditSessionRef.current = domEditSession;
@@ -446,10 +447,8 @@ export function StudioApp() {
446
447
  <DomEditProvider value={domEditSession}>
447
448
  <div
448
449
  className="flex flex-col h-full w-full bg-neutral-950 relative"
449
- onDragOver={dragOverlay.onDragOver}
450
- onDragEnter={dragOverlay.onDragEnter}
451
- onDragLeave={dragOverlay.onDragLeave}
452
- onDrop={dragOverlay.onDrop}
450
+ onDragOver={fileDrop.onDragOver}
451
+ onDrop={fileDrop.onDrop}
453
452
  >
454
453
  <StudioHeader
455
454
  captureFrameHref={frameCapture.captureFrameHref}
@@ -538,6 +537,10 @@ export function StudioApp() {
538
537
  handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
539
538
  handleRazorSplit={timelineEditing.handleRazorSplit}
540
539
  handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
540
+ onCopyClip={handleCopy}
541
+ onPasteClip={handlePaste}
542
+ onDuplicateClip={handleDuplicate}
543
+ canPasteClip={canPaste}
541
544
  setCompIdToSrc={setCompIdToSrc}
542
545
  setCompositionLoading={setCompositionLoading}
543
546
  shouldShowMotionPath={shouldShowMotionPath}
@@ -568,7 +571,6 @@ export function StudioApp() {
568
571
  clearConsoleErrors={() => setConsoleErrors(null)}
569
572
  domEditSession={domEditSession}
570
573
  activeCompPath={activeCompPath}
571
- dragOverlayActive={dragOverlay.active}
572
574
  toasts={toasts}
573
575
  dismissToast={dismissToast}
574
576
  />
@@ -86,6 +86,10 @@ describe("EditorShell timeline selection sync", () => {
86
86
  handleTimelineElementSplit={vi.fn()}
87
87
  handleRazorSplit={vi.fn()}
88
88
  handleRazorSplitAll={vi.fn()}
89
+ onCopyClip={vi.fn(() => false)}
90
+ onPasteClip={vi.fn(async () => {})}
91
+ onDuplicateClip={vi.fn(async () => false)}
92
+ canPasteClip={vi.fn(() => false)}
89
93
  setCompIdToSrc={vi.fn()}
90
94
  setCompositionLoading={vi.fn()}
91
95
  shouldShowMotionPath={false}
@@ -55,6 +55,10 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
55
55
  files: File[],
56
56
  placement?: TimelineDropPlacement,
57
57
  ) => Promise<void> | void;
58
+ onCopyClip: () => boolean;
59
+ onPasteClip: () => Promise<void>;
60
+ onDuplicateClip: () => Promise<boolean>;
61
+ canPasteClip: () => boolean;
58
62
  setCompIdToSrc: (map: Map<string, string>) => void;
59
63
  setCompositionLoading: (loading: boolean) => void;
60
64
  shouldShowMotionPath: boolean;
@@ -93,6 +97,10 @@ export function EditorShell({
93
97
  handleTimelineElementSplit,
94
98
  handleRazorSplit,
95
99
  handleRazorSplitAll,
100
+ onCopyClip,
101
+ onPasteClip,
102
+ onDuplicateClip,
103
+ canPasteClip,
96
104
  setCompIdToSrc,
97
105
  setCompositionLoading,
98
106
  shouldShowMotionPath,
@@ -181,6 +189,10 @@ export function EditorShell({
181
189
  onBlockDrop={handleTimelineBlockDrop}
182
190
  onCompositionDrop={handleTimelineCompositionDrop}
183
191
  onDeleteElement={handleTimelineElementDelete}
192
+ onCopyClip={onCopyClip}
193
+ onPasteClip={onPasteClip}
194
+ onDuplicateClip={onDuplicateClip}
195
+ canPasteClip={canPasteClip}
184
196
  previewOverlay={
185
197
  <PreviewOverlays
186
198
  shouldShowMotionPath={shouldShowMotionPath}
@@ -219,6 +231,10 @@ interface EditorShellBodyProps {
219
231
  placement: TimelineDropPlacement,
220
232
  ) => Promise<void> | void;
221
233
  onDeleteElement: (element: TimelineElement) => Promise<void> | void;
234
+ onCopyClip: () => boolean;
235
+ onPasteClip: () => Promise<void>;
236
+ onDuplicateClip: () => Promise<boolean>;
237
+ canPasteClip: () => boolean;
222
238
  }
223
239
 
224
240
  function EditorShellBody({
@@ -235,6 +251,10 @@ function EditorShellBody({
235
251
  onBlockDrop,
236
252
  onCompositionDrop,
237
253
  onDeleteElement,
254
+ onCopyClip,
255
+ onPasteClip,
256
+ onDuplicateClip,
257
+ canPasteClip,
238
258
  }: EditorShellBodyProps) {
239
259
  const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
240
260
 
@@ -289,6 +309,10 @@ function EditorShellBody({
289
309
  onBlockDrop={onBlockDrop}
290
310
  onCompositionDrop={onCompositionDrop}
291
311
  onDeleteElement={onDeleteElement}
312
+ onCopyClip={onCopyClip}
313
+ onPasteClip={onPasteClip}
314
+ onDuplicateClip={onDuplicateClip}
315
+ canPasteClip={canPasteClip}
292
316
  onSelectTimelineElement={onSelectTimelineElement}
293
317
  timelineFooter={
294
318
  captionEditMode ? (
@@ -1,7 +1,6 @@
1
1
  import type { ComponentProps } from "react";
2
2
  import { LintModal } from "./LintModal";
3
3
  import { AskAgentModal } from "./AskAgentModal";
4
- import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
5
4
  import { StudioToast } from "./StudioToast";
6
5
  import { StudioFeedbackCard } from "./feedback/StudioFeedbackCard";
7
6
  import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
@@ -19,14 +18,13 @@ export interface StudioOverlaysProps {
19
18
  clearConsoleErrors: () => void;
20
19
  domEditSession: ReturnType<typeof useDomEditSession>;
21
20
  activeCompPath: string | null;
22
- dragOverlayActive: boolean;
23
21
  toasts: ReturnType<typeof useToast>["toasts"];
24
22
  dismissToast: (id: number) => void;
25
23
  }
26
24
 
27
25
  /**
28
26
  * Floating overlays for the studio shell: lint / console-error modals, the
29
- * ask-agent modal, the global drag overlay, and the toast. Extracted from
27
+ * ask-agent modal, and the toast. Extracted from
30
28
  * `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
31
29
  */
32
30
  // fallow-ignore-next-line complexity
@@ -39,7 +37,6 @@ export function StudioOverlays({
39
37
  clearConsoleErrors,
40
38
  domEditSession,
41
39
  activeCompPath,
42
- dragOverlayActive,
43
40
  toasts,
44
41
  dismissToast,
45
42
  }: StudioOverlaysProps) {
@@ -78,7 +75,6 @@ export function StudioOverlays({
78
75
  }}
79
76
  />
80
77
  )}
81
- {dragOverlayActive && <StudioGlobalDragOverlay />}
82
78
  {/* One bottom-right stack so the feedback card and toasts queue instead
83
79
  of covering each other. Empty when nothing is showing. */}
84
80
  <div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
@@ -0,0 +1,88 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { PreviewGuides } from "./PreviewGuides";
7
+ import { usePreviewGuidesStore } from "./previewGuidesStore";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const compRect = vi.hoisted(() => ({
12
+ current: { left: 20, top: 20, width: 400, height: 225, scaleX: 1, scaleY: 1 },
13
+ }));
14
+ vi.mock("./useDomEditCompositionRect", () => ({
15
+ useDomEditCompositionRect: () => compRect.current,
16
+ }));
17
+
18
+ afterEach(() => {
19
+ compRect.current = { left: 20, top: 20, width: 400, height: 225, scaleX: 1, scaleY: 1 };
20
+ document.body.innerHTML = "";
21
+ window.localStorage.clear();
22
+ });
23
+
24
+ function render(rulerVisible: boolean, safeMarginsVisible: boolean) {
25
+ usePreviewGuidesStore.setState({ rulerVisible, safeMarginsVisible });
26
+ const host = document.createElement("div");
27
+ document.body.append(host);
28
+ act(() => {
29
+ createRoot(host).render(<PreviewGuides iframeRef={{ current: null }} />);
30
+ });
31
+ return host;
32
+ }
33
+
34
+ describe("PreviewGuides", () => {
35
+ it("draws nothing when both toggles are off", () => {
36
+ const host = render(false, false);
37
+ expect(host.querySelector("[data-testid]")).toBeNull();
38
+ });
39
+
40
+ it("draws the ruler with ticks 0 to 100 every 10 on both edges", () => {
41
+ const host = render(true, false);
42
+ const top = host.querySelector('[data-testid="preview-ruler-top"]');
43
+ expect(Array.from(top?.children ?? []).map((t) => t.textContent)).toEqual(
44
+ Array.from({ length: 11 }, (_, i) => String(i * 10)),
45
+ );
46
+ expect(host.querySelector('[data-testid="preview-ruler-left"]')).not.toBeNull();
47
+ expect(host.querySelector('[data-testid="preview-safe-90"]')).toBeNull();
48
+ expect(host.querySelector('[data-testid="preview-safe-80"]')).toBeNull();
49
+ });
50
+
51
+ it("draws the 90% and 80% boxes with edge-midpoint ticks and no labels or caption band", () => {
52
+ const host = render(false, true);
53
+ const box = (pct: number) =>
54
+ host.querySelector<HTMLElement>(`[data-testid="preview-safe-${pct}"]`);
55
+ expect([box(90)?.style.left, box(90)?.style.bottom]).toEqual(["5%", "5%"]);
56
+ expect([box(80)?.style.top, box(80)?.style.right]).toEqual(["10%", "10%"]);
57
+ expect(box(90)?.children.length).toBe(4);
58
+ expect(box(80)?.children.length).toBe(4);
59
+ expect(host.textContent).toBe("");
60
+ expect(host.querySelector('[data-testid="preview-ruler-top"]')).toBeNull();
61
+ });
62
+
63
+ it("places the ruler in the gutter outside the frame", () => {
64
+ const host = render(true, false);
65
+ const top = host.querySelector<HTMLElement>('[data-testid="preview-ruler-top"]');
66
+ const left = host.querySelector<HTMLElement>('[data-testid="preview-ruler-left"]');
67
+ expect([top?.style.left, top?.style.top, top?.style.width]).toEqual(["20px", "4px", "400px"]);
68
+ expect([left?.style.left, left?.style.top, left?.style.height]).toEqual([
69
+ "4px",
70
+ "20px",
71
+ "225px",
72
+ ]);
73
+ });
74
+
75
+ it("draws nothing until the composition has a size", () => {
76
+ compRect.current = { left: 0, top: 0, width: 0, height: 0, scaleX: 1, scaleY: 1 };
77
+ const host = render(true, true);
78
+ expect(host.querySelector("[data-testid]")).toBeNull();
79
+ });
80
+
81
+ it("draws the same boxes on a portrait composition", () => {
82
+ compRect.current = { left: 20, top: 20, width: 225, height: 400, scaleX: 1, scaleY: 1 };
83
+ const host = render(false, true);
84
+ const box = host.querySelector<HTMLElement>('[data-testid="preview-safe-90"]');
85
+ expect([box?.style.left, box?.style.top]).toEqual(["5%", "5%"]);
86
+ expect(host.querySelector('[data-testid="preview-safe-80"]')).not.toBeNull();
87
+ });
88
+ });
@@ -0,0 +1,125 @@
1
+ import { memo, useRef, type RefObject } from "react";
2
+ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
3
+ import { RULER_GUTTER_PX, usePreviewGuidesStore } from "./previewGuidesStore";
4
+ import { SAFE_BOX_PERCENTS, safeBoxInsetPercent } from "../../utils/previewSafeMargins";
5
+
6
+ interface PreviewGuidesProps {
7
+ iframeRef: RefObject<HTMLIFrameElement | null>;
8
+ }
9
+
10
+ const TICKS = Array.from({ length: 11 }, (_, i) => i * 10);
11
+ const INK = "rgba(255,255,255,0.7)";
12
+
13
+ /** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */
14
+ export const PreviewGuides = memo(function PreviewGuides({ iframeRef }: PreviewGuidesProps) {
15
+ const rulerVisible = usePreviewGuidesStore((s) => s.rulerVisible);
16
+ const safeMarginsVisible = usePreviewGuidesStore((s) => s.safeMarginsVisible);
17
+ if (!rulerVisible && !safeMarginsVisible) return null;
18
+ return (
19
+ <ActiveGuides
20
+ iframeRef={iframeRef}
21
+ rulerVisible={rulerVisible}
22
+ safeMarginsVisible={safeMarginsVisible}
23
+ />
24
+ );
25
+ });
26
+
27
+ interface ActiveGuidesProps extends PreviewGuidesProps {
28
+ rulerVisible: boolean;
29
+ safeMarginsVisible: boolean;
30
+ }
31
+
32
+ /** Owns the composition-rect subscription, so it only runs while a guide is on. */
33
+ function ActiveGuides({ iframeRef, rulerVisible, safeMarginsVisible }: ActiveGuidesProps) {
34
+ const paneRef = useRef<HTMLDivElement>(null);
35
+ const rect = useDomEditCompositionRect({ iframeRef, overlayRef: paneRef });
36
+ const ready = rect.width > 0 && rect.height > 0;
37
+
38
+ return (
39
+ <div ref={paneRef} aria-hidden="true" className="pointer-events-none absolute inset-0 z-20">
40
+ {ready && rulerVisible && (
41
+ <>
42
+ <div
43
+ data-testid="preview-ruler-top"
44
+ className="absolute bg-black/70"
45
+ style={{
46
+ left: rect.left,
47
+ top: rect.top - RULER_GUTTER_PX,
48
+ width: rect.width,
49
+ height: RULER_GUTTER_PX,
50
+ }}
51
+ >
52
+ {TICKS.map((pct) => (
53
+ <span
54
+ key={pct}
55
+ className="absolute bottom-0 border-l text-[9px] leading-none tabular-nums pl-0.5 h-2.5"
56
+ style={{ left: `${pct}%`, borderColor: INK, color: INK }}
57
+ >
58
+ {pct}
59
+ </span>
60
+ ))}
61
+ </div>
62
+ <div
63
+ data-testid="preview-ruler-left"
64
+ className="absolute bg-black/70"
65
+ style={{
66
+ left: rect.left - RULER_GUTTER_PX,
67
+ top: rect.top,
68
+ width: RULER_GUTTER_PX,
69
+ height: rect.height,
70
+ }}
71
+ >
72
+ {TICKS.map((pct) => (
73
+ <span
74
+ key={pct}
75
+ className="absolute right-0 border-t text-[9px] leading-none tabular-nums pt-0.5 w-2.5"
76
+ style={{ top: `${pct}%`, borderColor: INK, color: INK }}
77
+ >
78
+ {pct}
79
+ </span>
80
+ ))}
81
+ </div>
82
+ </>
83
+ )}
84
+ {ready && safeMarginsVisible && (
85
+ <div
86
+ className="absolute"
87
+ style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
88
+ >
89
+ {SAFE_BOX_PERCENTS.map((boxPercent) => (
90
+ <SafeBox key={boxPercent} boxPercent={boxPercent} />
91
+ ))}
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ const TICK_PX = 7;
99
+ const EDGE_MIDPOINTS = [
100
+ { edge: "top", style: { left: "50%", top: -(TICK_PX + 1) / 2, width: 1, height: TICK_PX } },
101
+ { edge: "bottom", style: { left: "50%", bottom: -(TICK_PX + 1) / 2, width: 1, height: TICK_PX } },
102
+ { edge: "left", style: { top: "50%", left: -(TICK_PX + 1) / 2, width: TICK_PX, height: 1 } },
103
+ { edge: "right", style: { top: "50%", right: -(TICK_PX + 1) / 2, width: TICK_PX, height: 1 } },
104
+ ] as const;
105
+
106
+ /** One thin white box inset from every edge, with a tick at the midpoint of each edge. */
107
+ function SafeBox({ boxPercent }: { boxPercent: number }) {
108
+ const inset = `${safeBoxInsetPercent(boxPercent)}%`;
109
+ return (
110
+ <div
111
+ data-testid={`preview-safe-${boxPercent}`}
112
+ className="absolute border border-white/90"
113
+ style={{
114
+ left: inset,
115
+ top: inset,
116
+ right: inset,
117
+ bottom: inset,
118
+ }}
119
+ >
120
+ {EDGE_MIDPOINTS.map(({ edge, style }) => (
121
+ <span key={edge} className="absolute bg-white/90" style={style} />
122
+ ))}
123
+ </div>
124
+ );
125
+ }
@@ -8,6 +8,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
8
8
  import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
9
9
  import type { DomEditSelection } from "./domEditing";
10
10
  import { SnapToolbar } from "./SnapToolbar";
11
+ import { usePreviewGuidesStore } from "./previewGuidesStore";
11
12
 
12
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
14
 
@@ -53,6 +54,7 @@ function AppHotkeyHarness() {
53
54
  handleCopy: vi.fn(() => false),
54
55
  handlePaste: vi.fn(async () => undefined),
55
56
  handleCut: vi.fn(async () => false),
57
+ handleDuplicate: vi.fn(async () => false),
56
58
  onResetKeyframes: vi.fn(() => false),
57
59
  onDeleteSelectedKeyframes: vi.fn(),
58
60
  });
@@ -119,3 +121,22 @@ describe("SnapToolbar keyboard shortcuts", () => {
119
121
  act(() => root.unmount());
120
122
  });
121
123
  });
124
+
125
+ describe("SnapToolbar ruler and safe-margin toggles", () => {
126
+ it.each([
127
+ ["Toggle ruler", "rulerVisible"],
128
+ ["Toggle safe margins", "safeMarginsVisible"],
129
+ ])("%s flips %s and remembers it", (label, key) => {
130
+ usePreviewGuidesStore.setState({ rulerVisible: false, safeMarginsVisible: false });
131
+ const { root } = renderToolbar();
132
+ const button = () => document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`);
133
+ expect(button()?.getAttribute("aria-pressed")).toBe("false");
134
+
135
+ act(() => button()?.click());
136
+
137
+ expect(button()?.getAttribute("aria-pressed")).toBe("true");
138
+ const stored = window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}";
139
+ expect(JSON.parse(stored)[key]).toBe(true);
140
+ act(() => root.unmount());
141
+ });
142
+ });
@@ -1,7 +1,8 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
- import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
2
+ import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
3
3
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
4
4
  import { usePlayerStore } from "../../player/store/playerStore";
5
+ import { usePreviewGuidesStore } from "./previewGuidesStore";
5
6
 
6
7
  const SNAP_DEFAULTS = {
7
8
  snapEnabled: true,
@@ -39,6 +40,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
39
40
  const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
40
41
  const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
41
42
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
43
+ const guides = usePreviewGuidesStore();
42
44
  const popoverRef = useRef<HTMLDivElement>(null);
43
45
  const gridButtonRef = useRef<HTMLButtonElement>(null);
44
46
 
@@ -116,6 +118,28 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
116
118
  <Path size={16} weight={motionPathArmed ? "fill" : "regular"} />
117
119
  </button>
118
120
  )}
121
+ {(
122
+ [
123
+ ["rulerVisible", "Ruler", Ruler],
124
+ ["safeMarginsVisible", "Safe margins", FrameCorners],
125
+ ] as const
126
+ ).map(([key, label, Icon]) => (
127
+ <button
128
+ key={key}
129
+ type="button"
130
+ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
131
+ guides[key]
132
+ ? "bg-studio-accent/20 text-studio-accent"
133
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
134
+ }`}
135
+ onClick={() => guides.toggle(key)}
136
+ title={`${label} ${guides[key] ? "on" : "off"}`}
137
+ aria-label={`Toggle ${label.toLowerCase()}`}
138
+ aria-pressed={guides[key]}
139
+ >
140
+ <Icon size={16} weight={guides[key] ? "fill" : "regular"} />
141
+ </button>
142
+ ))}
119
143
  <button
120
144
  type="button"
121
145
  className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
@@ -96,6 +96,7 @@ export function buildElementAgentPrompt({
96
96
  selectionContext,
97
97
  userInstruction,
98
98
  sourceFilePath,
99
+ timeline,
99
100
  }: {
100
101
  selection: DomEditSelection;
101
102
  currentTime: number;
@@ -103,6 +104,8 @@ export function buildElementAgentPrompt({
103
104
  selectionContext?: string;
104
105
  userInstruction?: string;
105
106
  sourceFilePath?: string;
107
+ /** The `formatTimelineBlock` text; omitted or empty when the timeline has no clips. */
108
+ timeline?: string;
106
109
  }): string {
107
110
  const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
108
111
  const info: AgentPromptElementInfo = {
@@ -136,6 +139,8 @@ export function buildElementAgentPrompt({
136
139
 
137
140
  lines.push(...buildElementDetailLines(info));
138
141
 
142
+ if (timeline) lines.push("", timeline);
143
+
139
144
  if (tagSnippet) {
140
145
  lines.push("", "Target HTML:", tagSnippet);
141
146
  }
@@ -0,0 +1,26 @@
1
+ import { create } from "zustand";
2
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
3
+
4
+ /** Space the preview fit reserves above and left of the frame while the ruler is on. */
5
+ export const RULER_GUTTER_PX = 16;
6
+
7
+ type GuideKey = "rulerVisible" | "safeMarginsVisible";
8
+
9
+ interface PreviewGuidesState {
10
+ rulerVisible: boolean;
11
+ safeMarginsVisible: boolean;
12
+ toggle: (key: GuideKey) => void;
13
+ }
14
+
15
+ export const usePreviewGuidesStore = create<PreviewGuidesState>((set, get) => {
16
+ const stored = readStudioUiPreferences();
17
+ return {
18
+ rulerVisible: stored.rulerVisible ?? false,
19
+ safeMarginsVisible: stored.safeMarginsVisible ?? false,
20
+ toggle: (key) => {
21
+ const next = !get()[key];
22
+ writeStudioUiPreferences({ [key]: next });
23
+ set({ [key]: next });
24
+ },
25
+ };
26
+ });
@@ -134,6 +134,19 @@ describe("getPreviewPlayerKey", () => {
134
134
  });
135
135
 
136
136
  describe("resolvePreviewStageSize", () => {
137
+ it("reserves the ruler gutter on both sides of both axes", () => {
138
+ const wide = { width: 1920, height: 1080 };
139
+ const tall = { width: 1080, height: 1920 };
140
+ expect(resolvePreviewStageSize(512, 402, wide, undefined, 16)).toEqual({
141
+ width: 464,
142
+ height: 261,
143
+ });
144
+ expect(resolvePreviewStageSize(512, 402, tall, undefined, 16)).toEqual({
145
+ width: 199.125,
146
+ height: 354,
147
+ });
148
+ });
149
+
137
150
  it("fits portrait composition dimensions by height in a narrow viewport", () => {
138
151
  expect(resolvePreviewStageSize(512, 402, { width: 1080, height: 1920 }, undefined)).toEqual({
139
152
  width: 217.125,
@@ -11,6 +11,7 @@ import {
11
11
  toDomPrecision,
12
12
  type PreviewZoomState,
13
13
  } from "./previewZoom";
14
+ import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
14
15
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
15
16
  interface NLEPreviewProps {
16
17
  projectId: string;
@@ -89,9 +90,10 @@ export function resolvePreviewStageSize(
89
90
  viewportHeight: number,
90
91
  compositionSize: PreviewCompositionSize | null,
91
92
  portrait: boolean | undefined,
93
+ gutterPx = 0,
92
94
  ): { width: number; height: number } {
93
- const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX);
94
- const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX);
95
+ const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX - 2 * gutterPx);
96
+ const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX - 2 * gutterPx);
95
97
  const aspectRatio =
96
98
  compositionSize && compositionSize.width > 0 && compositionSize.height > 0
97
99
  ? compositionSize.width / compositionSize.height
@@ -135,6 +137,7 @@ export const NLEPreview = memo(function NLEPreview({
135
137
  onStageRef?.(stageRef);
136
138
  }, [onStageRef]);
137
139
  const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
140
+ const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
138
141
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
139
142
 
140
143
  const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
@@ -164,14 +167,16 @@ export const NLEPreview = memo(function NLEPreview({
164
167
 
165
168
  const updateStageSize = () => {
166
169
  const rect = viewport.getBoundingClientRect();
167
- setStageSize(resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait));
170
+ setStageSize(
171
+ resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait, gutterPx),
172
+ );
168
173
  };
169
174
 
170
175
  updateStageSize();
171
176
  const observer = new ResizeObserver(updateStageSize);
172
177
  observer.observe(viewport);
173
178
  return () => observer.disconnect();
174
- }, [compositionSize, portrait]);
179
+ }, [compositionSize, portrait, gutterPx]);
175
180
 
176
181
  const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
177
182
  onCompositionSizeChangeRef.current = onCompositionSizeChange;