@hyperframes/studio 0.8.80 → 0.8.82

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 (110) hide show
  1. package/dist/assets/{hyperframes-player-CgnoxDoD.js → hyperframes-player-zGIbZhLJ.js} +1 -1
  2. package/dist/assets/{index-J9HXe7mU.js → index-BiLCTunu.js} +1 -1
  3. package/dist/assets/{index-CsG24Jub.js → index-DSazIt7R.js} +198 -198
  4. package/dist/assets/{index-DZGVvqDT.js → index-DStmfnpZ.js} +1 -1
  5. package/dist/index.d.ts +172 -7
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +2567 -2314
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +8 -9
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  13. package/src/components/TimelineHistoryButtons.tsx +26 -14
  14. package/src/components/TimelineToolbar.test.tsx +9 -1
  15. package/src/components/TimelineToolbar.tsx +13 -5
  16. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  17. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  18. package/src/components/editor/manualEdits.test.ts +24 -0
  19. package/src/components/editor/manualEdits.ts +6 -2
  20. package/src/components/editor/manualEditsDom.ts +0 -65
  21. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +71 -32
  23. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  24. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  25. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  26. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  27. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  28. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  29. package/src/components/editor/useFxLevelling.ts +35 -10
  30. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  31. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  32. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  33. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  34. package/src/components/panels/VariablesPanel.tsx +3 -3
  35. package/src/components/pointerTargetSize.test.tsx +1 -1
  36. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  37. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  38. package/src/components/studioColorGradingScope.ts +16 -42
  39. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  40. package/src/contexts/TimelineEditContext.tsx +27 -23
  41. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  42. package/src/hooks/gsapMutationClient.ts +116 -0
  43. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  44. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  45. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  46. package/src/hooks/timelineEditingHelpers.ts +66 -61
  47. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  48. package/src/hooks/timelineTimingSync.test.ts +12 -0
  49. package/src/hooks/timelineTimingSync.ts +31 -123
  50. package/src/hooks/timelineTrackVisibility.ts +15 -29
  51. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  52. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  53. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  54. package/src/hooks/useAskAgentModal.ts +3 -1
  55. package/src/hooks/useClipboard.source.test.ts +32 -0
  56. package/src/hooks/useClipboard.ts +21 -20
  57. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  58. package/src/hooks/useDomEditCommits.ts +88 -97
  59. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  60. package/src/hooks/useEditorSave.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.ts +36 -37
  62. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  63. package/src/hooks/useFileManager.ts +21 -12
  64. package/src/hooks/useGroupCommits.ts +23 -24
  65. package/src/hooks/useGsapAnimationOps.ts +26 -8
  66. package/src/hooks/useGsapScriptCommits.ts +1 -0
  67. package/src/hooks/useLintModal.test.tsx +15 -0
  68. package/src/hooks/useLintModal.ts +5 -2
  69. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  70. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  71. package/src/hooks/useThumbnailStripSize.ts +31 -0
  72. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  73. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  74. package/src/hooks/useTimelineEditing.ts +3 -1
  75. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  76. package/src/index.ts +8 -0
  77. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  78. package/src/player/components/CompositionThumbnail.tsx +6 -21
  79. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  80. package/src/player/components/ImageThumbnail.tsx +4 -18
  81. package/src/player/components/PlayerControls.test.tsx +52 -0
  82. package/src/player/components/PlayerControls.tsx +2 -1
  83. package/src/player/components/Timeline.test.ts +28 -3
  84. package/src/player/components/TimelineCanvas.tsx +5 -3
  85. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  86. package/src/player/components/VideoThumbnail.tsx +7 -29
  87. package/src/player/components/thumbnailUtils.test.ts +16 -20
  88. package/src/player/components/thumbnailUtils.ts +6 -7
  89. package/src/player/components/timelineEditing.test.ts +55 -0
  90. package/src/player/components/timelineGroupEditing.ts +13 -6
  91. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  92. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  93. package/src/player/components/useTimelinePlayhead.ts +26 -8
  94. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  95. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  96. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  97. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  98. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  99. package/src/player/lib/playbackShortcuts.ts +18 -0
  100. package/src/player/lib/playbackTypes.ts +1 -0
  101. package/src/player/lib/timelineDOM.test.ts +92 -0
  102. package/src/player/lib/timelineDOM.ts +0 -2
  103. package/src/player/lib/timelineElementHelpers.ts +47 -10
  104. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  105. package/src/player/store/timelineElement.ts +2 -1
  106. package/src/timelineChromeExports.test.tsx +79 -0
  107. package/src/utils/authoredSource.ts +15 -0
  108. package/src/utils/blockInstaller.ts +32 -27
  109. package/src/utils/studioFileHistory.test.ts +14 -14
  110. package/src/utils/studioFileHistory.ts +31 -2
@@ -5,11 +5,11 @@ import { buildProjectApiPath } from "../utils/projectRouting";
5
5
  import { useCallback, useRef, type MutableRefObject, type RefObject } from "react";
6
6
  import type { TimelineElement } from "../player";
7
7
  import { usePlayerStore } from "../player";
8
- import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
8
+ import { saveServerRewriteWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
9
9
  import { studioWriteHeaders } from "../utils/studioFileVersion";
10
10
  import { getTimelineElementLabel } from "../utils/studioHelpers";
11
11
  import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
12
- import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
12
+ import { captureDurationRollback } from "./timelineTimingSync";
13
13
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
14
14
  import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
15
15
  import {
@@ -100,71 +100,68 @@ export function useTimelineDeleteOps({
100
100
  (candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
101
101
  );
102
102
  try {
103
- const originalContent = await readFileContent(pid, targetPath);
104
-
105
- // Remove every selected element before saving once. The server rewrites
106
- // the file per call, so `removedContent` after the last one holds them
107
- // all — which is what makes this a single history entry, and a single
108
- // undo, rather than one per clip.
109
- let removedContent = originalContent;
110
- for (const target of sameFile) {
111
- const patchTarget = buildPatchTarget(target);
112
- if (!patchTarget) {
113
- throw new Error(`Timeline element ${target.id} is missing a patchable target`);
114
- }
115
-
116
- const removeResponse = await fetch(
117
- buildProjectApiPath(
118
- pid,
119
- `/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
120
- ),
121
- {
122
- method: "POST",
123
- headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
124
- body: JSON.stringify({ target: patchTarget }),
125
- },
126
- );
127
- if (!removeResponse.ok) {
128
- throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
129
- }
130
-
131
- const removeData = (await removeResponse.json()) as {
132
- changed?: boolean;
133
- content?: string;
134
- };
135
- if (typeof removeData.content === "string") removedContent = removeData.content;
136
- }
137
- // Content-driven duration: shrink the composition to the furthest
138
- // remaining clip end, read from the post-removal SOURCE (raw
139
- // data-duration), so deleting the last/longest clip removes trailing
140
- // empty space. Measured from the source, not the store, whose
141
- // durations are runtime-truncated.
142
- const deleteContentEnd = furthestClipEndFromSource(removedContent);
143
- const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
144
- // Optimistically reflect the shrunk length in the readout/seek bar,
145
- // rolling it back if the persist below fails (see captureDurationRollback).
146
- const rollbackDuration = captureDurationRollback(previewIframeRef.current);
147
- if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
148
- usePlayerStore.getState().setDuration(deleteContentEnd);
149
- }
150
-
151
103
  // Shared with the ripple move below so a folded ripple is one undo
152
104
  // step with the delete, not two (editHistory.ts coalesces by key +
153
105
  // window across separate recordEdit calls, not by label).
154
106
  const coalesceKey = `main-track-ripple-delete:${deleteGestureSeq++}`;
155
107
  const deleteHistoryLabel = "Delete timeline clip";
108
+ let rollbackDuration = () => {};
156
109
  try {
157
- await saveProjectFilesWithHistory({
110
+ await saveServerRewriteWithHistory({
158
111
  projectId: pid,
112
+ path: targetPath,
159
113
  label: deleteHistoryLabel,
160
114
  coalesceKey,
161
- files: { [targetPath]: patchedContent },
162
- readFile: async () => originalContent,
163
- // remove-element already wrote the removal, so disk holds THAT — not the
164
- // content read at the top. Undo still goes back to the original.
165
- diskContent: { [targetPath]: removedContent },
166
115
  writeFile: writeProjectFile,
167
116
  recordEdit,
117
+ rewrite: async (originalContent) => {
118
+ // Remove every selected element before saving once. The server rewrites
119
+ // the file per call, so `removedContent` after the last one holds them
120
+ // all — which is what makes this a single history entry, and a single
121
+ // undo, rather than one per clip.
122
+ let removedContent = originalContent;
123
+ for (const target of sameFile) {
124
+ const patchTarget = buildPatchTarget(target);
125
+ if (!patchTarget) {
126
+ throw new Error(`Timeline element ${target.id} is missing a patchable target`);
127
+ }
128
+
129
+ const removeResponse = await fetch(
130
+ buildProjectApiPath(
131
+ pid,
132
+ `/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
133
+ ),
134
+ {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
137
+ body: JSON.stringify({ target: patchTarget }),
138
+ },
139
+ );
140
+ if (!removeResponse.ok) {
141
+ throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
142
+ }
143
+
144
+ const removeData = (await removeResponse.json()) as {
145
+ changed?: boolean;
146
+ content?: string;
147
+ };
148
+ if (typeof removeData.content === "string") removedContent = removeData.content;
149
+ }
150
+ // Shrink to the furthest remaining clip end, read from the post-removal source:
151
+ // store durations are runtime-truncated.
152
+ const deleteContentEnd = furthestClipEndFromSource(removedContent);
153
+ const patchedContent = setCompositionDurationToContent(
154
+ removedContent,
155
+ deleteContentEnd,
156
+ );
157
+ // Optimistically reflect the shrunk length in the readout/seek bar,
158
+ // rolling it back if the persist below fails (see captureDurationRollback).
159
+ rollbackDuration = captureDurationRollback(previewIframeRef.current);
160
+ if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
161
+ usePlayerStore.getState().setDuration(deleteContentEnd);
162
+ }
163
+ return { disk: removedContent, after: patchedContent };
164
+ },
168
165
  });
169
166
  } catch (error) {
170
167
  rollbackDuration();
@@ -8,7 +8,6 @@ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
8
8
  import {
9
9
  applyTimelineStackingReorder,
10
10
  patchIframeDomTiming,
11
- playbackStartAttributeForElement,
12
11
  persistTimelineEdit,
13
12
  formatTimelineAttributeNumber,
14
13
  formatTimelineMediaOffset,
@@ -16,6 +15,7 @@ import {
16
15
  buildTimelineMoveTimingPatch,
17
16
  buildTimelineResizeTimingPatch,
18
17
  } from "./timelineEditingHelpers";
18
+ import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
19
19
  import {
20
20
  captureDurationRollback,
21
21
  finishClipTimingFallback,
@@ -243,6 +243,7 @@ export function useTimelineEditing({
243
243
  label: "Move timeline clip",
244
244
  coalesceKey,
245
245
  recordEdit,
246
+ writeProjectFile,
246
247
  edit: { kind: "shift", delta: updates.start - element.start },
247
248
  }).finally(() => invalidateGsapCache?.());
248
249
  const moveFallback = () =>
@@ -346,6 +347,7 @@ export function useTimelineEditing({
346
347
  label: "Resize timeline clip",
347
348
  coalesceKey,
348
349
  recordEdit,
350
+ writeProjectFile,
349
351
  edit: {
350
352
  kind: "scale",
351
353
  from: { start: element.start, duration: element.duration },
@@ -15,11 +15,11 @@ import {
15
15
  formatTimelineAttributeNumber,
16
16
  formatTimelineMediaOffset,
17
17
  patchIframeDomTiming,
18
- playbackStartAttributeForElement,
19
18
  persistTimelineBatchEdit,
20
19
  type PersistTimelineBatchChange,
21
20
  type RecordEditInput,
22
21
  } from "./timelineEditingHelpers";
22
+ import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
23
23
  import {
24
24
  captureDurationRollback,
25
25
  finishGroupTimingGsapFallback,
@@ -330,6 +330,7 @@ export function useTimelineGroupEditing({
330
330
  errorLabel: "Failed to shift GSAP positions",
331
331
  coalesceKey,
332
332
  recordEdit,
333
+ writeProjectFile,
333
334
  activeCompPath,
334
335
  changes,
335
336
  resolveChangePath: (element) => targetPathFor(element, activeCompPath),
@@ -365,6 +366,7 @@ export function useTimelineGroupEditing({
365
366
  trySdkBatchPersist,
366
367
  showToast,
367
368
  invalidateGsapCache,
369
+ writeProjectFile,
368
370
  ],
369
371
  );
370
372
 
@@ -437,6 +439,7 @@ export function useTimelineGroupEditing({
437
439
  errorLabel: "Failed to scale GSAP positions",
438
440
  coalesceKey,
439
441
  recordEdit,
442
+ writeProjectFile,
440
443
  activeCompPath,
441
444
  changes,
442
445
  resolveChangePath: (element) => targetPathFor(element, activeCompPath),
@@ -478,6 +481,7 @@ export function useTimelineGroupEditing({
478
481
  trySdkBatchPersist,
479
482
  showToast,
480
483
  invalidateGsapCache,
484
+ writeProjectFile,
481
485
  ],
482
486
  );
483
487
 
package/src/index.ts CHANGED
@@ -184,6 +184,14 @@ export type { TimelinePaneProps } from "./components/nle/TimelinePane";
184
184
  export { TimelineEditProvider } from "./contexts/TimelineEditContext";
185
185
  export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
186
186
  export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";
187
+ export { TimelineToolbar } from "./components/TimelineToolbar";
188
+ export type { TimelineToolbarProps } from "./components/TimelineToolbar";
189
+ export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
190
+ export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
191
+ export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
192
+ export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
193
+ export { useClipboard } from "./hooks/useClipboard";
194
+ export type { UseClipboardOptions } from "./hooks/useClipboard";
187
195
 
188
196
  export {
189
197
  playSeamTransitionLoop,
@@ -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
  }}