@hyperframes/studio 0.8.81 → 0.8.83

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
  4. package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +834 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +26378 -28802
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  29. package/src/components/TimelineHistoryButtons.tsx +26 -14
  30. package/src/components/TimelineToolbar.test.tsx +9 -1
  31. package/src/components/TimelineToolbar.tsx +13 -5
  32. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  33. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  34. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  35. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  36. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  37. package/src/components/editor/DomEditOverlay.tsx +36 -21
  38. package/src/components/editor/LayersPanel.tsx +1 -1
  39. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  40. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  41. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  42. package/src/components/editor/clipAudioClock.test.ts +61 -0
  43. package/src/components/editor/clipAudioClock.ts +78 -0
  44. package/src/components/editor/manualEdits.test.ts +24 -0
  45. package/src/components/editor/manualEdits.ts +6 -2
  46. package/src/components/editor/manualEditsDom.ts +0 -65
  47. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  48. package/src/components/editor/marqueeCommit.ts +100 -37
  49. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
  51. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  52. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  53. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  54. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  55. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  56. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  57. package/src/components/editor/useFxCarve.ts +11 -15
  58. package/src/components/editor/useFxCarveNodes.ts +27 -15
  59. package/src/components/editor/useFxLevelling.ts +20 -31
  60. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  61. package/src/components/feedback/projectProvenance.ts +2 -3
  62. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  63. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  64. package/src/components/nle/PreviewOverlays.tsx +7 -188
  65. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  66. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  67. package/src/components/pointerTargetSize.test.tsx +1 -1
  68. package/src/components/studioColorGradingScope.ts +12 -36
  69. package/src/components/ui/HyperframesLogo.tsx +120 -0
  70. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  71. package/src/contexts/TimelineEditContext.tsx +27 -23
  72. package/src/domEditExports.test.tsx +69 -0
  73. package/src/editorGesturesExports.test.tsx +236 -0
  74. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  75. package/src/hooks/gsapMutationClient.ts +116 -0
  76. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  77. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  78. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  79. package/src/hooks/timelineEditingHelpers.ts +66 -61
  80. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  81. package/src/hooks/timelineTimingSync.test.ts +12 -0
  82. package/src/hooks/timelineTimingSync.ts +31 -123
  83. package/src/hooks/timelineTrackVisibility.ts +15 -29
  84. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  85. package/src/hooks/useAskAgentModal.ts +3 -1
  86. package/src/hooks/useClipboard.source.test.ts +32 -0
  87. package/src/hooks/useClipboard.ts +21 -20
  88. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  89. package/src/hooks/useDomEditCommits.ts +88 -97
  90. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  91. package/src/hooks/useEditorSave.ts +1 -1
  92. package/src/hooks/useElementLifecycleOps.ts +37 -38
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  94. package/src/hooks/useFileManager.ts +21 -12
  95. package/src/hooks/useGroupCommits.ts +23 -24
  96. package/src/hooks/useGsapAnimationOps.ts +26 -8
  97. package/src/hooks/useGsapScriptCommits.ts +1 -0
  98. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  99. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  100. package/src/hooks/usePreviewPersistence.ts +1 -1
  101. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  102. package/src/hooks/useThumbnailStripSize.ts +31 -0
  103. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  104. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  105. package/src/hooks/useTimelineEditing.ts +3 -1
  106. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  107. package/src/index.ts +53 -32
  108. package/src/lightEntries.test.ts +32 -0
  109. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  110. package/src/player/components/CompositionThumbnail.tsx +6 -21
  111. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  112. package/src/player/components/ImageThumbnail.tsx +4 -18
  113. package/src/player/components/PlayerControls.test.tsx +52 -0
  114. package/src/player/components/PlayerControls.tsx +2 -1
  115. package/src/player/components/Timeline.test.ts +28 -3
  116. package/src/player/components/TimelineCanvas.tsx +5 -3
  117. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  118. package/src/player/components/TimelineClipFades.tsx +2 -1
  119. package/src/player/components/TimelineTypes.ts +4 -2
  120. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  121. package/src/player/components/VideoThumbnail.tsx +7 -29
  122. package/src/player/components/studioShortcuts.ts +8 -5
  123. package/src/player/components/thumbnailUtils.test.ts +16 -20
  124. package/src/player/components/thumbnailUtils.ts +6 -7
  125. package/src/player/components/timelineClipChildren.test.ts +34 -1
  126. package/src/player/components/timelineClipChildren.tsx +7 -4
  127. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  128. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  129. package/src/player/components/timelineEditing.test.ts +55 -0
  130. package/src/player/components/timelineGroupEditing.ts +13 -6
  131. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  132. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  133. package/src/player/components/timelinePointerRelease.ts +4 -0
  134. package/src/player/components/useTimelinePlayhead.ts +26 -8
  135. package/src/player/components/useTimelineProviderState.tsx +4 -0
  136. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  137. package/src/player/components/useTimelineStackingSync.ts +18 -11
  138. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  139. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  140. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  141. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  142. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  143. package/src/player/lib/playbackShortcuts.ts +18 -0
  144. package/src/player/lib/playbackTypes.ts +1 -0
  145. package/src/player/lib/timelineDOM.test.ts +92 -0
  146. package/src/player/lib/timelineDOM.ts +0 -2
  147. package/src/player/lib/timelineElementHelpers.ts +47 -10
  148. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  149. package/src/player/store/timelineElement.ts +2 -1
  150. package/src/playerEntry.ts +1 -0
  151. package/src/timelineChromeExports.test.tsx +79 -0
  152. package/src/timelineStackingSyncExport.test.tsx +67 -0
  153. package/src/ui.ts +31 -0
  154. package/src/utils/authoredSource.ts +15 -0
  155. package/src/utils/blockInstaller.ts +32 -27
  156. package/src/utils/mediaTypes.test.ts +33 -0
  157. package/src/utils/mediaTypes.ts +12 -5
  158. package/src/utils/studioFileHistory.test.ts +14 -14
  159. package/src/utils/studioFileHistory.ts +31 -2
  160. package/dist/chunk-I7XNAIOF.js.map +0 -1
  161. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
package/src/index.ts CHANGED
@@ -1,35 +1,5 @@
1
1
  // UI primitives
2
- export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
3
- export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
4
- export { HyperframesLogo } from "./components/StudioHeader";
5
- export { IconButton } from "./components/ui/IconButton";
6
- export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
7
- export { HyperframesLoader } from "./components/ui/HyperframesLoader";
8
- export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
9
- export { Tooltip } from "./components/ui/Tooltip";
10
- export { cn } from "./components/ui/cn";
11
- export {
12
- ContextMenu,
13
- Menu,
14
- MenuItem,
15
- MenuRadioGroup,
16
- MenuRadioItem,
17
- MenuSeparator,
18
- MenuShortcut,
19
- popupSurface,
20
- } from "./components/ui/Menu";
21
- export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
22
- export { Popover } from "./components/ui/Popover";
23
- export { Input, fieldBase, fieldText } from "./components/ui/Input";
24
- export type { InputProps } from "./components/ui/Input";
25
- export { NumberField } from "./components/ui/NumberField";
26
- export type { NumberFieldProps } from "./components/ui/NumberField";
27
- export { Select } from "./components/ui/Select";
28
- export type { SelectOption, SelectProps } from "./components/ui/Select";
29
- export { Slider } from "./components/ui/Slider";
30
- export type { SliderProps } from "./components/ui/Slider";
31
- export { Toggle } from "./components/ui/Toggle";
32
- export type { ToggleProps } from "./components/ui/Toggle";
2
+ export * from "./ui";
33
3
 
34
4
  // NLE Layout
35
5
  export { EditorShell } from "./components/EditorShell";
@@ -154,7 +124,10 @@ export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/h
154
124
  export { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
155
125
  export type { UsePersistentEditHistoryOptions } from "./hooks/usePersistentEditHistory";
156
126
  export { useTimelineEditing } from "./hooks/useTimelineEditing";
157
- export type { UseTimelineEditingOptions } from "./hooks/useTimelineEditingTypes";
127
+ export type {
128
+ TimelineZIndexReorderCommit,
129
+ UseTimelineEditingOptions,
130
+ } from "./hooks/useTimelineEditingTypes";
158
131
  // A host's own waitForPendingDomEditSaves must also call this, or undo/redo
159
132
  // can race a write still in flight (see useTrackPendingTimelineEdit.ts).
160
133
  export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
@@ -184,6 +157,25 @@ export type { TimelinePaneProps } from "./components/nle/TimelinePane";
184
157
  export { TimelineEditProvider } from "./contexts/TimelineEditContext";
185
158
  export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
186
159
  export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";
160
+ export { TimelineToolbar } from "./components/TimelineToolbar";
161
+ export type { TimelineToolbarProps } from "./components/TimelineToolbar";
162
+ export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
163
+ export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
164
+ export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
165
+ export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
166
+ export { useClipboard } from "./hooks/useClipboard";
167
+ export type { UseClipboardOptions } from "./hooks/useClipboard";
168
+
169
+ // DOM editing: the canvas session, persistence and overlay, for a host outside EditorShell.
170
+ export { useDomEditSession } from "./hooks/useDomEditSession";
171
+ export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
172
+ export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
173
+ export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
174
+ export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
175
+ export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
176
+ export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
177
+ export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
178
+ export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
187
179
 
188
180
  export {
189
181
  playSeamTransitionLoop,
@@ -191,3 +183,32 @@ export {
191
183
  type SeamTransitionLoopHandle,
192
184
  type SeamTransitionLoopOptions,
193
185
  } from "@hyperframes/shader-transitions";
186
+
187
+ // Editor gestures for a host's own preview overlay: snapping and the marquee
188
+ export {
189
+ SNAP_THRESHOLD_PX,
190
+ snapEngagedForTravel,
191
+ extractSnapTargets,
192
+ buildCompositionSnapTarget,
193
+ buildGridSnapEdges,
194
+ resolveSnapAdjustment,
195
+ resolveGuideLineRect,
196
+ resolveEquidistanceGuides,
197
+ } from "./components/editor/snapEngine";
198
+ export type {
199
+ SnapEdge,
200
+ SnapTarget,
201
+ SnapGuide,
202
+ SpacingGuide,
203
+ SnapResult,
204
+ Rect as SnapRect,
205
+ } from "./components/editor/snapEngine";
206
+ export { collectSnapContext } from "./components/editor/snapTargetCollection";
207
+ export type { SnapContext } from "./components/editor/snapTargetCollection";
208
+ export { SnapGuideOverlay } from "./components/editor/SnapGuideOverlay";
209
+ export type { SnapGuideOverlayProps, SnapGuidesState } from "./components/editor/SnapGuideOverlay";
210
+ export { useMarqueeGestures } from "./components/editor/marqueeCommit";
211
+ export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/marqueeCommit";
212
+ export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
213
+ export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
214
+ export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
@@ -0,0 +1,32 @@
1
+ // @vitest-environment happy-dom
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ const packageDir = resolve(import.meta.dirname, "..");
7
+ const { subpaths } = JSON.parse(readFileSync(resolve(packageDir, "package-subpaths.json"), "utf8"));
8
+
9
+ function refuse(name: string): never {
10
+ throw new Error(`a light entry loaded ${name}`);
11
+ }
12
+ vi.mock("@hyperframes/core", () => refuse("@hyperframes/core"));
13
+ vi.mock("@hyperframes/shader-transitions", () => refuse("@hyperframes/shader-transitions"));
14
+ vi.mock("@codemirror/view", () => refuse("@codemirror/view"));
15
+ vi.mock("@codemirror/state", () => refuse("@codemirror/state"));
16
+ vi.mock("mediabunny", () => refuse("mediabunny"));
17
+ vi.mock("gsap", () => refuse("gsap"));
18
+ vi.mock("@hyperframes/parsers", () => refuse("@hyperframes/parsers"));
19
+ vi.mock("@hyperframes/parsers/hf-ids", () => refuse("@hyperframes/parsers/hf-ids"));
20
+ vi.mock("@hyperframes/sdk", () => refuse("@hyperframes/sdk"));
21
+ vi.mock("dockview-react", () => refuse("dockview-react"));
22
+
23
+ describe("entries a host imports for its first screen", () => {
24
+ it.each(["./ui", "./player"])(
25
+ "%s loads without the editor, the parsers, gsap or media decoding",
26
+ async (entry) => {
27
+ await expect(import(resolve(packageDir, subpaths[entry].source))).resolves.toBeDefined();
28
+ },
29
+ // The icon set alone is thousands of modules to transform on a cold run.
30
+ 30_000,
31
+ );
32
+ });
@@ -3,6 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
6
7
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
7
8
  import { buildCompositionThumbnailUrl, CompositionThumbnail } from "./CompositionThumbnail";
8
9
 
@@ -11,12 +12,6 @@ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
11
12
  value: true,
12
13
  });
13
14
 
14
- class MockResizeObserver {
15
- observe() {}
16
- disconnect() {}
17
- unobserve() {}
18
- }
19
-
20
15
  class MockImage {
21
16
  static instances: MockImage[] = [];
22
17
  onload: (() => void) | null = null;
@@ -146,6 +141,45 @@ describe("CompositionThumbnail", () => {
146
141
  );
147
142
  });
148
143
 
144
+ it.each([
145
+ { name: "a wide", width: 2700, height: 1000, tileWidth: 108 },
146
+ { name: "a square", width: 1000, height: 1000, tileWidth: 48 },
147
+ { name: "a portrait", width: 1080, height: 1920, tileWidth: 48 },
148
+ ])(
149
+ "shows $name picture whole at the clip's measured height",
150
+ async ({ width, height, tileWidth }) => {
151
+ Object.defineProperty(host, "clientWidth", { configurable: true, value: 500 });
152
+ Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
153
+ const probe = await renderThumbnail();
154
+
155
+ await act(async () => {
156
+ probe.naturalWidth = width;
157
+ probe.naturalHeight = height;
158
+ probe.onload?.();
159
+ await new Promise((resolve) => setTimeout(resolve, 0));
160
+ });
161
+
162
+ const img = host.querySelector("img")!;
163
+ expect(img.parentElement?.style.width).toBe(`${tileWidth}px`);
164
+ // A tile held at its minimum width letterboxes the picture instead of cropping it.
165
+ expect(img.classList.contains("object-contain")).toBe(true);
166
+ },
167
+ );
168
+
169
+ it("re-tiles at the height the resize observer reports", async () => {
170
+ const probe = await renderThumbnail();
171
+ await act(async () => {
172
+ probe.naturalWidth = 2700;
173
+ probe.naturalHeight = 1000;
174
+ probe.onload?.();
175
+ await new Promise((resolve) => setTimeout(resolve, 0));
176
+ });
177
+
178
+ act(() => reportResize(500, 40));
179
+
180
+ expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
181
+ });
182
+
149
183
  it("aborts its scheduled off-DOM image probe when unmounted", async () => {
150
184
  const probe = await renderThumbnail();
151
185
  expect(host.querySelector("img")).toBeNull();
@@ -1,6 +1,6 @@
1
- import { memo, useCallback, useMemo, useRef, useState, type CSSProperties } from "react";
2
- import { useMountEffect } from "../../hooks/useMountEffect";
1
+ import { memo, useMemo, type CSSProperties } from "react";
3
2
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
3
+ import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
4
4
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
5
5
  import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
6
6
  import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
@@ -22,7 +22,6 @@ interface CompositionThumbnailProps {
22
22
  rich?: boolean;
23
23
  }
24
24
 
25
- const CLIP_HEIGHT = 66;
26
25
  const THUMBNAIL_URL_VERSION = "v3";
27
26
  export const THUMBNAIL_SEEK_TIME_SECONDS = 3;
28
27
 
@@ -122,8 +121,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
122
121
  contentRevision = 0,
123
122
  priority = "visible",
124
123
  }: CompositionThumbnailProps) {
125
- const [containerWidth, setContainerWidth] = useState(0);
126
- const observerRef = useRef<ResizeObserver | null>(null);
124
+ const [container, setContainerRef] = useThumbnailStripSize();
127
125
  const url = buildCompositionThumbnailUrl({
128
126
  previewUrl,
129
127
  seekTime,
@@ -149,25 +147,12 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
149
147
  const value =
150
148
  snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value : null;
151
149
  const { frameW, frameCount } = computeThumbnailStrip(
152
- containerWidth,
150
+ container.width,
153
151
  value?.aspect ?? 16 / 9,
154
- CLIP_HEIGHT,
152
+ container.height,
155
153
  48,
156
154
  );
157
155
 
158
- const setContainerRef = useCallback((element: HTMLDivElement | null) => {
159
- observerRef.current?.disconnect();
160
- if (!element) return;
161
- const target = element.parentElement ?? element;
162
- setContainerWidth(target.clientWidth);
163
- observerRef.current = new ResizeObserver(([entry]) =>
164
- setContainerWidth(entry.contentRect.width),
165
- );
166
- observerRef.current.observe(target);
167
- }, []);
168
-
169
- useMountEffect(() => () => observerRef.current?.disconnect());
170
-
171
156
  return (
172
157
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
173
158
  {value && (
@@ -189,7 +174,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
189
174
  src={value.url}
190
175
  alt=""
191
176
  draggable={false}
192
- className="absolute inset-0 h-full w-full object-cover"
177
+ className="absolute inset-0 h-full w-full object-contain"
193
178
  style={{ opacity: "var(--timeline-composition-thumbnail-opacity)" }}
194
179
  />
195
180
  </div>
@@ -3,6 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
6
7
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
7
8
  import { ImageThumbnail } from "./ImageThumbnail";
8
9
 
@@ -30,12 +31,6 @@ class MockIntersectionObserver {
30
31
  }
31
32
  }
32
33
 
33
- class MockResizeObserver {
34
- observe() {}
35
- disconnect() {}
36
- unobserve() {}
37
- }
38
-
39
34
  // --- Image stub: captures instances so tests fire load/error deterministically ---
40
35
  class MockImage {
41
36
  static instances: MockImage[] = [];
@@ -171,6 +166,32 @@ describe("ImageThumbnail", () => {
171
166
  expect(host.querySelector(".animate-pulse")).toBeNull();
172
167
  });
173
168
 
169
+ it("tiles a wide picture at the clip's measured height, whole", async () => {
170
+ Object.defineProperty(host, "clientWidth", { configurable: true, value: 500 });
171
+ Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
172
+ render({ imageSrc: "/api/projects/p/preview/assets/wide.png" });
173
+ await resolveProbe((probe) => {
174
+ probe.naturalWidth = 2700;
175
+ probe.naturalHeight = 1000;
176
+ probe.onload?.();
177
+ });
178
+
179
+ expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
180
+ });
181
+
182
+ it("re-tiles at the height the resize observer reports", async () => {
183
+ render({ imageSrc: "/api/projects/p/preview/assets/wide.png" });
184
+ await resolveProbe((probe) => {
185
+ probe.naturalWidth = 2700;
186
+ probe.naturalHeight = 1000;
187
+ probe.onload?.();
188
+ });
189
+
190
+ act(() => reportResize(500, 40));
191
+
192
+ expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
193
+ });
194
+
174
195
  it("renders the label above the strip when provided", async () => {
175
196
  render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
176
197
  await resolveProbe((probe) => {
@@ -1,6 +1,6 @@
1
- import { memo, useCallback, useMemo, useRef, useState } from "react";
2
- import { useMountEffect } from "../../hooks/useMountEffect";
1
+ import { memo, useMemo } from "react";
3
2
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
3
+ import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
4
4
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
5
5
  import { decodeImageThumbnail } from "../lib/thumbnailImageDecoder";
6
6
  import { computeThumbnailStrip } from "./thumbnailUtils";
@@ -25,8 +25,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
25
25
  priority = "visible",
26
26
  rich = false,
27
27
  }: ImageThumbnailProps) {
28
- const [containerWidth, setContainerWidth] = useState(0);
29
- const observerRef = useRef<ResizeObserver | null>(null);
28
+ const [container, setContainerRef] = useThumbnailStripSize();
30
29
  const request = useMemo(
31
30
  () => ({
32
31
  key: createThumbnailKey({ kind: "image", source: imageSrc, rich: Number(rich) }),
@@ -42,20 +41,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
42
41
  const snapshot = useThumbnailLease(request);
43
42
  const value = snapshot.status === "ready" ? snapshot.value : null;
44
43
  const aspect = value?.kind === "image" ? value.aspect : 16 / 9;
45
- const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect);
46
-
47
- const setContainerRef = useCallback((element: HTMLDivElement | null) => {
48
- observerRef.current?.disconnect();
49
- if (!element) return;
50
- const target = element.parentElement ?? element;
51
- setContainerWidth(target.clientWidth);
52
- observerRef.current = new ResizeObserver(([entry]) =>
53
- setContainerWidth(entry.contentRect.width),
54
- );
55
- observerRef.current.observe(target);
56
- }, []);
57
-
58
- useMountEffect(() => () => observerRef.current?.disconnect());
44
+ const { frameW, frameCount } = computeThumbnailStrip(container.width, aspect, container.height);
59
45
 
60
46
  return (
61
47
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
@@ -0,0 +1,52 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { liveTime, usePlayerStore } from "../store/playerStore";
7
+ import { PlayerControls } from "./PlayerControls";
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
+
11
+ let host: HTMLDivElement;
12
+ let root: Root;
13
+
14
+ beforeEach(async () => {
15
+ usePlayerStore.setState({
16
+ isPlaying: true,
17
+ currentTime: 2,
18
+ duration: 30,
19
+ timeDisplayMode: "time",
20
+ });
21
+ host = document.createElement("div");
22
+ document.body.append(host);
23
+ root = createRoot(host);
24
+ await act(async () =>
25
+ root.render(React.createElement(PlayerControls, { onTogglePlay: () => {}, onSeek: () => {} })),
26
+ );
27
+ });
28
+
29
+ afterEach(async () => {
30
+ await act(async () => root.unmount());
31
+ host.remove();
32
+ });
33
+
34
+ describe("PlayerControls timecode", () => {
35
+ it("rewrites the timecode only when the shown text changes", async () => {
36
+ const timecode = host.querySelector("button.font-mono > span");
37
+ expect(timecode?.textContent).toBe("00:02");
38
+ const writes: MutationRecord[] = [];
39
+ const observer = new MutationObserver((records) => writes.push(...records));
40
+ observer.observe(timecode as Node, { childList: true, characterData: true, subtree: true });
41
+
42
+ for (let frame = 1; frame <= 30; frame++) liveTime.notify(2 + frame / 120);
43
+ await Promise.resolve();
44
+ expect(writes).toHaveLength(0);
45
+
46
+ liveTime.notify(3.01);
47
+ await Promise.resolve();
48
+ expect(timecode?.textContent).toBe("00:03");
49
+ expect(writes.length).toBeGreaterThan(0);
50
+ observer.disconnect();
51
+ });
52
+ });
@@ -213,10 +213,11 @@ export const PlayerControls = memo(function PlayerControls({
213
213
  currentTimeRef.current = time;
214
214
  if (!timeDisplayRef.current) return;
215
215
  const currentDuration = durationRef.current;
216
- timeDisplayRef.current.textContent =
216
+ const text =
217
217
  timeDisplayModeRef.current === "frame"
218
218
  ? formatFrameTime(time, currentDuration)
219
219
  : formatTime(time);
220
+ if (timeDisplayRef.current.textContent !== text) timeDisplayRef.current.textContent = text;
220
221
  };
221
222
  const unsubscribe = liveTime.subscribe(updateTime);
222
223
  updateTime(usePlayerStore.getState().currentTime);
@@ -39,7 +39,7 @@ import {
39
39
  } from "./timelineLayout";
40
40
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
41
41
  import { formatTime } from "../lib/time";
42
- import { usePlayerStore } from "../store/playerStore";
42
+ import { liveTime, usePlayerStore } from "../store/playerStore";
43
43
  import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
44
44
 
45
45
  vi.mock("./timelineRowVirtualizationFlag", () => ({
@@ -153,6 +153,27 @@ function renderSharedAutomationTimeline(selectedElementId?: string) {
153
153
  return { host, root };
154
154
  }
155
155
 
156
+ describe("Timeline playhead motion", () => {
157
+ it("moves by fractional pixels while playing and snaps to device pixels once paused", () => {
158
+ usePlayerStore.setState({
159
+ duration: 11,
160
+ timelineReady: true,
161
+ currentTime: 10,
162
+ isPlaying: true,
163
+ zoomMode: "manual",
164
+ manualZoomPercent: 100,
165
+ elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }],
166
+ });
167
+ const { root, playhead } = renderTimelineGeometry("clip-1");
168
+ const wrapperLeft = GUTTER + TRACKS_LEFT_PAD + 1000.3 - PLAYHEAD_HEAD_W / 2;
169
+ act(() => liveTime.notify(10.003));
170
+ expect(playhead.style.transform).toBe(`translateX(${wrapperLeft}px)`);
171
+ act(() => usePlayerStore.setState({ isPlaying: false }));
172
+ expect(playhead.style.transform).toBe(`translateX(${Math.round(wrapperLeft)}px)`);
173
+ act(() => root.unmount());
174
+ });
175
+ });
176
+
156
177
  describe("Timeline provider boundary", () => {
157
178
  it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
158
179
  usePlayerStore.setState({
@@ -173,7 +194,9 @@ describe("Timeline provider boundary", () => {
173
194
  expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
174
195
  expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
175
196
  expect(rulerTick.style.left).toBe("999.5px");
176
- expect(playhead.style.left).toBe(`${GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2}px`);
197
+ expect(playhead.style.transform).toBe(
198
+ `translateX(${Math.round(GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2)}px)`,
199
+ );
177
200
  expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
178
201
  expect(
179
202
  resolveTimelineAssetDrop(
@@ -283,7 +306,9 @@ describe("Timeline provider boundary", () => {
283
306
  expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false);
284
307
  expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
285
308
  expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
286
- expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
309
+ expect(playhead.style.transform).toBe(
310
+ `translateX(${Math.round(LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2)}px)`,
311
+ );
287
312
  expect(diamondX).toBe(rulerX);
288
313
  expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
289
314
  expect(collapsedHeader.textContent).toContain("Outro");
@@ -9,10 +9,10 @@ import {
9
9
  TRACKS_BOTTOM_PAD,
10
10
  TRACK_H,
11
11
  PLAYHEAD_HEAD_W,
12
- getTimelinePlayheadLeft,
13
12
  getTimelineRowTop,
14
13
  getTimelineRowHeight,
15
14
  } from "./timelineLayout";
15
+ import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
16
16
  import { TimelineLanes } from "./TimelineLanes";
17
17
  import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
18
18
  import { useTimelineContext } from "./TimelineProvider";
@@ -234,7 +234,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
234
234
 
235
235
  {/* Playhead — hidden while dragging a beat so its guideline doesn't
236
236
  track the scrub and clutter the beat being moved. Explicit width +
237
- the half-head offset baked into getTimelinePlayheadLeft keep the
237
+ the half-head offset baked into getTimelinePlayheadTransform keep the
238
238
  inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
239
239
  ticks' center), instead of relying on shrink-wrap sizing. */}
240
240
  <TimelinePlayheadLayer scrollRef={props.scrollRef} contentOrigin={props.contentOrigin}>
@@ -242,7 +242,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
242
242
  ref={props.playheadRef}
243
243
  className="absolute top-0 bottom-0 pointer-events-none"
244
244
  style={{
245
- left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
245
+ left: 0,
246
+ transform: getTimelinePlayheadTransform(0, 0, props.contentOrigin, true),
247
+ willChange: "transform",
246
248
  width: PLAYHEAD_HEAD_W,
247
249
  display: beatDragging ? "none" : undefined,
248
250
  }}
@@ -71,8 +71,8 @@ function render(
71
71
  };
72
72
  }
73
73
 
74
- function pointer(type: string, clientX: number, pointerId = 1) {
75
- const event = new MouseEvent(type, { bubbles: true, clientX, button: 0 });
74
+ function pointer(type: string, clientX: number, pointerId = 1, clientY = 0) {
75
+ const event = new MouseEvent(type, { bubbles: true, clientX, clientY, button: 0 });
76
76
  Object.defineProperty(event, "pointerId", { value: pointerId });
77
77
  return event;
78
78
  }
@@ -134,14 +134,14 @@ describe("TimelineClipFades", () => {
134
134
  const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
135
135
  if (!handle) throw new Error("expected a fade-out handle");
136
136
  armCapture(handle);
137
- // Handle at x=800 (2 s before the end). 300 px left → 5 s.
138
- act(() => handle.dispatchEvent(pointer("pointerdown", 800)));
139
- act(() => handle.dispatchEvent(pointer("pointermove", 500)));
137
+ // Pressed at x=1000 with a 2 s fade-out. 300 px left → 5 s.
138
+ act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
139
+ act(() => handle.dispatchEvent(pointer("pointermove", 700)));
140
140
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
141
141
  // Way past the start: clamps to duration − fadeIn = 9 s.
142
- act(() => handle.dispatchEvent(pointer("pointermove", -500)));
142
+ act(() => handle.dispatchEvent(pointer("pointermove", 0)));
143
143
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "9");
144
- act(() => handle.dispatchEvent(pointer("pointerup", -500)));
144
+ act(() => handle.dispatchEvent(pointer("pointerup", 0)));
145
145
  expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-out", "9", "Fade out");
146
146
  act(() => root.unmount());
147
147
  });
@@ -151,9 +151,9 @@ describe("TimelineClipFades", () => {
151
151
  const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
152
152
  if (!handle) throw new Error("expected a fade-in handle");
153
153
  armCapture(handle);
154
- act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
155
- act(() => handle.dispatchEvent(pointer("pointermove", -40)));
156
- act(() => handle.dispatchEvent(pointer("pointerup", -40)));
154
+ act(() => handle.dispatchEvent(pointer("pointerdown", 200)));
155
+ act(() => handle.dispatchEvent(pointer("pointermove", 0)));
156
+ act(() => handle.dispatchEvent(pointer("pointerup", 0)));
157
157
  expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", null, "Fade in");
158
158
  act(() => root.unmount());
159
159
  });
@@ -179,6 +179,27 @@ describe("TimelineClipFades", () => {
179
179
  act(() => root.unmount());
180
180
  });
181
181
 
182
+ it.each([
183
+ ["above the window", 300, -40],
184
+ ["on the window's right edge", window.innerWidth, 10],
185
+ ])("restores the fade and saves nothing when released %s", (_, clientX, clientY) => {
186
+ const { host, root, onSetElementAttributeLive, onRevertElementAttributeLive, ...rest } =
187
+ render(clip);
188
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
189
+ if (!handle) throw new Error("expected a fade-in handle");
190
+ armCapture(handle);
191
+ act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
192
+ act(() => handle.dispatchEvent(pointer("pointermove", 300)));
193
+ act(() => handle.dispatchEvent(pointer("pointerup", clientX, 1, clientY)));
194
+ expect(rest.onSetElementAttributeQuiet).not.toHaveBeenCalled();
195
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
196
+ expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
197
+ expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
198
+ "0,0 100,0 0,100",
199
+ );
200
+ act(() => root.unmount());
201
+ });
202
+
182
203
  it("does not let a press on the dot start the clip's own move gesture", () => {
183
204
  const outer = vi.fn();
184
205
  const { host, root } = render(clip, { onClipPointerDown: outer });
@@ -14,6 +14,7 @@ import {
14
14
  } from "@hyperframes/core/audio-fade";
15
15
  import type { TimelineElement } from "../store/playerStore";
16
16
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
17
+ import { releasedOutsideWindow } from "./timelinePointerRelease";
17
18
 
18
19
  type FadeEdge = "in" | "out";
19
20
 
@@ -136,7 +137,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
136
137
  const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
137
138
  const g = endGesture(e);
138
139
  if (!g) return;
139
- if (cancelled || !g.moved) return revertGesture(g);
140
+ if (cancelled || !g.moved || releasedOutsideWindow(e)) return revertGesture(g);
140
141
  setDraft({ edge: g.edge, seconds: g.last });
141
142
  void onSetElementAttributeQuiet?.(
142
143
  el,
@@ -4,18 +4,20 @@ import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
4
4
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
5
5
  import type { TimelineTheme } from "./timelineTheme";
6
6
  import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
7
+ import type { TimelineStackingSyncProps } from "./useTimelineStackingSync";
7
8
 
8
9
  export interface TimelineClipRenderContext {
9
10
  priority: "overscan" | "visible" | "interaction";
10
11
  rich: boolean;
11
12
  }
12
13
 
13
- export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
14
+ export interface TimelineProps
15
+ extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps {
14
16
  /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
15
17
  sessionEpoch?: number;
16
- /** keepPlaying: true preserves the current play state across the seek. */
17
18
  onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
18
19
  onDrillDown?: (element: TimelineElement) => void;
20
+ /** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */
19
21
  renderClipContent?: (
20
22
  element: TimelineElement,
21
23
  style: { clip: string; label: string },