@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
@@ -2,6 +2,7 @@
2
2
  import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
5
6
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
6
7
  import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
7
8
  import { VideoThumbnail } from "./VideoThumbnail";
@@ -13,12 +14,6 @@ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
13
14
  value: true,
14
15
  });
15
16
 
16
- class MockResizeObserver {
17
- observe() {}
18
- disconnect() {}
19
- unobserve() {}
20
- }
21
-
22
17
  let host: HTMLDivElement;
23
18
  let root: Root | null = null;
24
19
 
@@ -36,8 +31,9 @@ afterEach(() => {
36
31
  document.body.innerHTML = "";
37
32
  });
38
33
 
39
- async function render(width = 0) {
34
+ async function render(width = 0, height = 40) {
40
35
  Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
36
+ Object.defineProperty(host, "clientHeight", { configurable: true, value: height });
41
37
  root = createRoot(host);
42
38
  await act(async () => {
43
39
  root!.render(
@@ -66,13 +62,13 @@ describe("VideoThumbnail", () => {
66
62
  expect(decodeVideoThumbnail).not.toHaveBeenCalled();
67
63
  });
68
64
 
69
- it("requests a geometry-sized filmstrip by default", async () => {
65
+ it("requests a filmstrip sized by the measured clip height", async () => {
70
66
  vi.mocked(decodeVideoThumbnail).mockResolvedValue({
71
67
  value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
72
68
  weight: 256,
73
69
  });
74
70
 
75
- await render(500);
71
+ await render(440);
76
72
 
77
73
  expect(decodeVideoThumbnail).toHaveBeenNthCalledWith(
78
74
  1,
@@ -93,7 +89,7 @@ describe("VideoThumbnail", () => {
93
89
  weight: 256,
94
90
  });
95
91
 
96
- await render(500);
92
+ await render(300);
97
93
 
98
94
  const tiles = [...host.querySelectorAll("img")].map((img) => img.getAttribute("src"));
99
95
  expect(tiles).toEqual(["blob:0", "blob:2", "blob:4", "blob:5", "blob:7"]);
@@ -105,11 +101,37 @@ describe("VideoThumbnail", () => {
105
101
  weight: 128,
106
102
  });
107
103
 
108
- await render(100);
104
+ await render(60);
109
105
 
110
106
  expect(decodeVideoThumbnail).toHaveBeenCalledTimes(1);
111
107
  });
112
108
 
109
+ it("tiles a wide picture at the clip's measured height, whole", async () => {
110
+ vi.mocked(decodeVideoThumbnail).mockResolvedValue({
111
+ value: { kind: "image", url: "blob:wide", aspect: 2.7 },
112
+ weight: 128,
113
+ });
114
+
115
+ await render(500, 40);
116
+
117
+ expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
118
+ });
119
+
120
+ it("re-tiles at the height the resize observer reports", async () => {
121
+ vi.mocked(decodeVideoThumbnail).mockResolvedValue({
122
+ value: { kind: "image", url: "blob:wide", aspect: 2.7 },
123
+ weight: 128,
124
+ });
125
+ await render(0, 0);
126
+
127
+ await act(async () => {
128
+ reportResize(500, 40);
129
+ await Promise.resolve();
130
+ });
131
+
132
+ expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
133
+ });
134
+
113
135
  it("clears the loading shimmer when the scheduled decode fails", async () => {
114
136
  vi.mocked(decodeVideoThumbnail).mockRejectedValue(new Error("decode failed"));
115
137
 
@@ -1,17 +1,13 @@
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 {
5
5
  createThumbnailKey,
6
6
  type ThumbnailPriority,
7
7
  type ThumbnailSnapshot,
8
8
  } from "../lib/thumbnailScheduler";
9
9
  import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
10
- import {
11
- computeThumbnailStrip,
12
- quantizeThumbnailFrameCount,
13
- THUMBNAIL_CLIP_HEIGHT,
14
- } from "./thumbnailUtils";
10
+ import { computeThumbnailStrip, quantizeThumbnailFrameCount } from "./thumbnailUtils";
15
11
 
16
12
  interface VideoThumbnailProps {
17
13
  videoSrc: string;
@@ -89,10 +85,9 @@ export const VideoThumbnail = memo(function VideoThumbnail({
89
85
  sessionEpoch = 0,
90
86
  priority = "visible",
91
87
  }: VideoThumbnailProps) {
92
- const [containerWidth, setContainerWidth] = useState(0);
93
- const observerRef = useRef<ResizeObserver | null>(null);
88
+ const [container, setContainerRef] = useThumbnailStripSize();
94
89
  const requestFrameCount = quantizeThumbnailFrameCount(
95
- computeThumbnailStrip(containerWidth, 16 / 9).frameCount,
90
+ computeThumbnailStrip(container.width, 16 / 9, container.height).frameCount,
96
91
  );
97
92
  const requestProps = useMemo(
98
93
  () => ({
@@ -114,7 +109,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
114
109
  () => createVideoThumbnailRequest(requestProps, requestFrameCount, true),
115
110
  [requestFrameCount, requestProps],
116
111
  );
117
- const measured = containerWidth > 0;
112
+ const measured = container.width > 0;
118
113
  const posterSnapshot = useThumbnailLease(measured ? posterRequest : null);
119
114
  const richSnapshot = useThumbnailLease(measured && requestFrameCount > 1 ? richRequest : null);
120
115
  const snapshot = selectThumbnailSnapshot(posterSnapshot, richSnapshot);
@@ -122,24 +117,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
122
117
  const urls =
123
118
  value?.kind === "filmstrip" ? value.urls : value?.kind === "image" ? [value.url] : [];
124
119
  const aspect = value?.kind === "image" || value?.kind === "filmstrip" ? value.aspect : 16 / 9;
125
- const { frameW, frameCount } = computeThumbnailStrip(
126
- containerWidth,
127
- aspect,
128
- THUMBNAIL_CLIP_HEIGHT,
129
- );
130
-
131
- const setContainerRef = useCallback((element: HTMLDivElement | null) => {
132
- observerRef.current?.disconnect();
133
- if (!element) return;
134
- const target = element.parentElement ?? element;
135
- setContainerWidth(target.clientWidth);
136
- observerRef.current = new ResizeObserver(([entry]) =>
137
- setContainerWidth(entry.contentRect.width),
138
- );
139
- observerRef.current.observe(target);
140
- }, []);
141
-
142
- useMountEffect(() => () => observerRef.current?.disconnect());
120
+ const { frameW, frameCount } = computeThumbnailStrip(container.width, aspect, container.height);
143
121
 
144
122
  return (
145
123
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
@@ -4,49 +4,45 @@ import {
4
4
  encodePreviewPath,
5
5
  resolveMediaPreviewUrl,
6
6
  quantizeThumbnailFrameCount,
7
- THUMBNAIL_CLIP_HEIGHT,
8
7
  } from "./thumbnailUtils";
9
8
 
10
9
  describe("computeThumbnailStrip", () => {
11
10
  it("sizes tiles by aspect ratio at the clip height", () => {
12
- const { frameW } = computeThumbnailStrip(500, 16 / 9);
13
- expect(frameW).toBe(Math.round(THUMBNAIL_CLIP_HEIGHT * (16 / 9)));
11
+ expect(computeThumbnailStrip(500, 16 / 9, 40).frameW).toBe(71);
12
+ expect(computeThumbnailStrip(500, 2.7, 40).frameW).toBe(108);
14
13
  });
15
14
 
16
15
  it("repeats tiles to cover the container width", () => {
17
- const { frameW, frameCount } = computeThumbnailStrip(500, 1);
18
- expect(frameW).toBe(THUMBNAIL_CLIP_HEIGHT);
19
- expect(frameCount).toBe(Math.ceil(500 / THUMBNAIL_CLIP_HEIGHT));
16
+ const { frameW, frameCount } = computeThumbnailStrip(500, 1, 40);
17
+ expect(frameW).toBe(40);
18
+ expect(frameCount).toBe(13);
20
19
  expect(frameCount * frameW).toBeGreaterThanOrEqual(500);
21
20
  });
22
21
 
23
22
  it("paints tiles across the full clip past the shared visible-frame budget", () => {
24
- expect(computeThumbnailStrip(14_400, 16 / 9).frameCount).toBeGreaterThan(33);
23
+ expect(computeThumbnailStrip(14_400, 16 / 9, 40).frameCount).toBeGreaterThan(33);
25
24
  });
26
25
 
27
- it("returns one tile when the container width is unknown", () => {
28
- expect(computeThumbnailStrip(0, 16 / 9).frameCount).toBe(1);
29
- expect(computeThumbnailStrip(-10, 16 / 9).frameCount).toBe(1);
26
+ it("returns one tile until the container is measured", () => {
27
+ expect(computeThumbnailStrip(0, 16 / 9, 40).frameCount).toBe(1);
28
+ expect(computeThumbnailStrip(-10, 16 / 9, 40).frameCount).toBe(1);
29
+ expect(computeThumbnailStrip(500, 16 / 9, 0).frameCount).toBe(1);
30
30
  });
31
31
 
32
32
  it("falls back to 16:9 for degenerate aspects", () => {
33
- const expected = Math.round(THUMBNAIL_CLIP_HEIGHT * (16 / 9));
34
- expect(computeThumbnailStrip(300, 0).frameW).toBe(expected);
35
- expect(computeThumbnailStrip(300, -2).frameW).toBe(expected);
36
- expect(computeThumbnailStrip(300, Number.NaN).frameW).toBe(expected);
37
- expect(computeThumbnailStrip(300, Number.POSITIVE_INFINITY).frameW).toBe(expected);
33
+ const expected = Math.round(40 * (16 / 9));
34
+ expect(computeThumbnailStrip(300, 0, 40).frameW).toBe(expected);
35
+ expect(computeThumbnailStrip(300, -2, 40).frameW).toBe(expected);
36
+ expect(computeThumbnailStrip(300, Number.NaN, 40).frameW).toBe(expected);
37
+ expect(computeThumbnailStrip(300, Number.POSITIVE_INFINITY, 40).frameW).toBe(expected);
38
38
  });
39
39
 
40
40
  it("never returns a zero-width tile (avoids divide-by-zero repeat counts)", () => {
41
- const { frameW, frameCount } = computeThumbnailStrip(300, 0.001);
41
+ const { frameW, frameCount } = computeThumbnailStrip(300, 0.001, 40);
42
42
  expect(frameW).toBeGreaterThanOrEqual(1);
43
43
  expect(Number.isFinite(frameCount)).toBe(true);
44
44
  });
45
45
 
46
- it("honors a custom clip height", () => {
47
- expect(computeThumbnailStrip(300, 2, 40).frameW).toBe(80);
48
- });
49
-
50
46
  it("keeps narrow tiles above a caller-owned minimum", () => {
51
47
  expect(computeThumbnailStrip(300, 0.25, 40, 48)).toEqual({
52
48
  frameW: 48,
@@ -1,7 +1,5 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { MAX_VISIBLE_THUMBNAIL_FRAMES } from "../lib/timelineViewportBudgets";
3
- /** Rendered height of a timeline-clip thumbnail strip, in CSS px. */
4
- export const THUMBNAIL_CLIP_HEIGHT = 66;
5
3
 
6
4
  export interface ThumbnailStripLayout {
7
5
  /** Width of a single tile, in CSS px. */
@@ -63,19 +61,20 @@ export function probeImageAspect(
63
61
  }
64
62
 
65
63
  /**
66
- * Compute the film-strip tile layout for a clip thumbnail: fixed-height tiles
67
- * sized by the media's aspect ratio, repeated to fill the clip width.
68
- * Degenerate aspects (0, negative, NaN, Infinity) fall back to 16:9.
64
+ * Compute the film-strip tile layout for a clip thumbnail: tiles as tall as
65
+ * the measured strip, sized by the media's aspect ratio, repeated to fill the
66
+ * clip width. Degenerate aspects (0, negative, NaN, Infinity) fall back to 16:9.
69
67
  */
70
68
  export function computeThumbnailStrip(
71
69
  containerWidth: number,
72
70
  aspect: number,
73
- clipHeight: number = THUMBNAIL_CLIP_HEIGHT,
71
+ clipHeight: number,
74
72
  minFrameWidth = 1,
75
73
  ): ThumbnailStripLayout {
76
74
  const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9;
77
75
  const frameW = Math.max(minFrameWidth, Math.round(clipHeight * safeAspect));
78
- const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
76
+ const measured = containerWidth > 0 && clipHeight > 0;
77
+ const frameCount = measured ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
79
78
  return { frameW, frameCount };
80
79
  }
81
80
 
@@ -880,6 +880,61 @@ describe("resolveTimelineResize", () => {
880
880
  expect(next.start).toBe(2);
881
881
  });
882
882
 
883
+ function headTrimmedFullyRight(clip: { start: number; duration: number }) {
884
+ const single = resolveTimelineResize(
885
+ { ...clip, originClientX: 0, pixelsPerSecond: 100, minStart: 0, maxEnd: 10 },
886
+ "start",
887
+ 1000,
888
+ );
889
+ const group = resolveTimelineGroupResize([clip], "start", 10).members[0]!;
890
+ return { single: single.duration, group: group.duration };
891
+ }
892
+
893
+ it.each([
894
+ { start: 1, duration: 0.625 },
895
+ { start: 1.005, duration: 2 },
896
+ ])("keeps the minimum duration when a head trim runs off the grid (%o)", (clip) => {
897
+ const { single, group } = headTrimmedFullyRight(clip);
898
+ expect(Math.min(single, group)).toBeGreaterThanOrEqual(0.1);
899
+ });
900
+
901
+ it("keeps the minimum duration for any off-grid start and length on a 1 ms grid", () => {
902
+ const short: string[] = [];
903
+ for (let i = 0; i < 20; i++) {
904
+ for (let j = 0; j < 18; j++) {
905
+ const clip = { start: (1000 + i * 13) / 1000, duration: (100 + j * 17) / 1000 };
906
+ const { single, group } = headTrimmedFullyRight(clip);
907
+ if (Math.min(single, group) < 0.1 - 1e-9) short.push(`${clip.start}+${clip.duration}`);
908
+ }
909
+ }
910
+ expect(short).toEqual([]);
911
+ });
912
+
913
+ it("stops a group head trim where the single-clip trim stops", () => {
914
+ const { single, group } = headTrimmedFullyRight({ start: 1.003, duration: 2 });
915
+ expect(group).toBe(single);
916
+ });
917
+
918
+ it("moves on-grid group members together when the tightest member sits 5 ms off the grid", () => {
919
+ const members = [
920
+ { start: 1.005, duration: 2 },
921
+ { start: 0, duration: 5 },
922
+ { start: 0.24, duration: 5 },
923
+ ];
924
+ const next = resolveTimelineGroupResize(members, "start", 10).members;
925
+ expect(next.map((m) => m.start)).toEqual([2.9, 1.89, 2.13]);
926
+ });
927
+
928
+ it("never moves a group head trim left of zero near the minimum length", () => {
929
+ const [member] = resolveTimelineGroupResize(
930
+ [{ start: 0.004, duration: 0.1 }],
931
+ "start",
932
+ 10,
933
+ ).members;
934
+ expect(member!.start).toBeGreaterThanOrEqual(0);
935
+ expect(member!.duration).toBeGreaterThanOrEqual(0.1);
936
+ });
937
+
883
938
  it("moves every group member by the same amount when one reaches its media start", () => {
884
939
  const members = [
885
940
  { start: 2, duration: 3, playbackStart: 1, playbackRate: 1.5 },
@@ -1,3 +1,4 @@
1
+ import { clampPlaybackRate } from "@hyperframes/parsers/media-duration";
1
2
  import { roundToCenti } from "../../utils/rounding";
2
3
  import type { TimelineElement } from "../store/playerStore";
3
4
  import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
@@ -19,13 +20,17 @@ function ceilTimelineTime(value: number): number {
19
20
  return Math.ceil((value - SAVED_MEDIA_OFFSET_TOLERANCE) * 100) / 100;
20
21
  }
21
22
 
23
+ function floorTimelineTime(value: number): number {
24
+ return Math.floor((value + SAVED_MEDIA_OFFSET_TOLERANCE) * 100) / 100;
25
+ }
26
+
22
27
  export function resolveTimelineMinDuration(minDuration?: number): number {
23
28
  return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION);
24
29
  }
25
30
 
26
31
  /** Playback rate never drops to zero (would make media-in-point math divide by ~0). */
27
- function resolveTimelinePlaybackRate(rate?: number): number {
28
- return Math.max(0.1, rate ?? 1);
32
+ export function resolveTimelinePlaybackRate(rate?: number): number {
33
+ return clampPlaybackRate(rate ?? 1);
29
34
  }
30
35
 
31
36
  interface TimelineStartTrimClip {
@@ -38,8 +43,8 @@ interface TimelineStartTrimClip {
38
43
  /**
39
44
  * Delta bounds for trimming a clip's START edge (shared by single-clip and group
40
45
  * resize). Left-bounded by how far the start can move toward `minStart` and by the
41
- * media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`.
42
- * Returned deltas are unrounded; applyClipStartTrimDelta does the rounding.
46
+ * media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`, the start
47
+ * floored onto the centisecond grid. Otherwise unrounded; applyClipStartTrimDelta rounds.
43
48
  */
44
49
  export function clipStartTrimDeltaBounds(
45
50
  clip: TimelineStartTrimClip,
@@ -51,7 +56,7 @@ export function clipStartTrimDeltaBounds(
51
56
  clip.playbackStart != null ? clip.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
52
57
  return {
53
58
  minDelta: -Math.min(clip.start - minStart, maxLeftExtensionFromMedia),
54
- maxDelta: clip.duration - minDuration,
59
+ maxDelta: floorTimelineTime(clip.start + clip.duration - minDuration) - clip.start,
55
60
  };
56
61
  }
57
62
 
@@ -137,7 +142,9 @@ export function clampTimelineGroupResizeDelta(
137
142
  const bounds = members.map((member) => clipStartTrimDeltaBounds(member, 0, minDuration));
138
143
  const minDelta = ceilTimelineTime(Math.max(...bounds.map((b) => b.minDelta)));
139
144
  const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
140
- return roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
145
+ const delta = roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
146
+ const roundedPastTightestMember = delta > maxDelta + SAVED_MEDIA_OFFSET_TOLERANCE;
147
+ return roundedPastTightestMember ? maxDelta - SAVED_MEDIA_OFFSET_TOLERANCE : delta;
141
148
  }
142
149
 
143
150
  export function resolveTimelineGroupResize(
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
3
+
4
+ const x = (transform: string) => Number(/translateX\(([-\d.e]+)px\)/.exec(transform)?.[1]);
5
+
6
+ describe("getTimelinePlayheadTransform", () => {
7
+ it("keeps fractional pixels while playing, so slow motion does not stair-step", () => {
8
+ // Fit zoom on a two-minute film is about 13.3px a second: 120 frames of one second of playback.
9
+ const frames = Array.from({ length: 121 }, (_, i) => i / 120);
10
+ const playing = new Set(frames.map((t) => getTimelinePlayheadTransform(t, 13.3, 80, false, 1)));
11
+ const resting = new Set(frames.map((t) => getTimelinePlayheadTransform(t, 13.3, 80, true, 1)));
12
+ expect(playing.size).toBe(121);
13
+ expect(resting.size).toBeLessThan(20);
14
+ });
15
+
16
+ it("at rest puts the line on the device pixel layout gives its ruler tick", () => {
17
+ // The 1px line sits 4px into the 9px wrapper; the tick is laid out at t * pps - 0.5 and rounded by layout.
18
+ for (const dpr of [1, 1.5, 2]) {
19
+ for (let i = 0; i <= 400; i += 1) {
20
+ const t = i * 0.0137;
21
+ const wrapper = x(getTimelinePlayheadTransform(t, 13.3, 80, true, dpr));
22
+ const lineDevicePx = Math.round((wrapper + 4) * dpr);
23
+ const tickDevicePx = Math.round((80 + t * 13.3 - 0.5) * dpr);
24
+ expect(lineDevicePx, `t=${t} dpr=${dpr}`).toBe(tickDevicePx);
25
+ }
26
+ }
27
+ });
28
+
29
+ it("at time zero at 1x lands the line on the tick's column", () => {
30
+ expect(getTimelinePlayheadTransform(0, 100, 80, true, 1)).toBe("translateX(76px)");
31
+ expect(Math.round(80 - 0.5)).toBe(76 + 4);
32
+ });
33
+ });
@@ -0,0 +1,17 @@
1
+ import { getTimelinePlayheadLeft } from "./timelineLayout";
2
+
3
+ /**
4
+ * Moves the playhead by transform, with no relayout. Playing, it keeps fractional pixels so slow motion stays
5
+ * smooth; at rest it rounds to device pixels the way layout rounds the ruler ticks, so the line sits on its tick.
6
+ */
7
+ export function getTimelinePlayheadTransform(
8
+ time: number,
9
+ pixelsPerSecond: number,
10
+ contentOrigin: number,
11
+ atRest: boolean,
12
+ devicePixelRatio = globalThis.devicePixelRatio || 1,
13
+ ): string {
14
+ const left = getTimelinePlayheadLeft(time, pixelsPerSecond, contentOrigin);
15
+ if (!atRest) return `translateX(${left}px)`;
16
+ return `translateX(${Math.round(left * devicePixelRatio) / devicePixelRatio}px)`;
17
+ }
@@ -4,12 +4,12 @@ import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { getPinchTimelineZoomPercent } from "./timelineZoom";
5
5
  import {
6
6
  getTimelinePlaybackFollowScrollLeft,
7
- getTimelinePlayheadLeft,
8
7
  getTimelineScrubTime,
9
8
  getTimelineScrollLeftForZoomTransition,
10
9
  getTimelineScrollLeftForZoomAnchor,
11
10
  shouldAutoScrollTimeline,
12
11
  } from "./timelineLayout";
12
+ import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
13
13
  import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing";
14
14
 
15
15
  interface UseTimelinePlayheadInput {
@@ -92,7 +92,12 @@ export function useTimelinePlayhead({
92
92
  const syncPlayheadPosition = useCallback(
93
93
  (time: number) => {
94
94
  if (!playheadRef.current || durationRef.current <= 0) return;
95
- playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current, contentOrigin)}px`;
95
+ playheadRef.current.style.transform = getTimelinePlayheadTransform(
96
+ time,
97
+ ppsRef.current,
98
+ contentOrigin,
99
+ !usePlayerStore.getState().isPlaying,
100
+ );
96
101
  },
97
102
  [playheadRef, durationRef, ppsRef, contentOrigin],
98
103
  );
@@ -122,14 +127,24 @@ export function useTimelinePlayhead({
122
127
  }, [zoomMode, scrollRef]);
123
128
 
124
129
  useMountEffect(() => {
125
- const unsub = liveTime.subscribe((t) => {
126
- if (!playheadRef.current || durationRef.current <= 0) return;
127
- const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
128
- playheadRef.current.style.left = `${getTimelinePlayheadLeft(
130
+ let lastLiveTime = usePlayerStore.getState().currentTime;
131
+ const place = (t: number, atRest: boolean) => {
132
+ if (!playheadRef.current || durationRef.current <= 0) return false;
133
+ playheadRef.current.style.transform = getTimelinePlayheadTransform(
129
134
  t,
130
135
  ppsRef.current,
131
136
  contentOriginRef.current,
132
- )}px`;
137
+ atRest,
138
+ );
139
+ return true;
140
+ };
141
+ const unsubPlaying = usePlayerStore.subscribe((state, prev) => {
142
+ if (prev.isPlaying && !state.isPlaying) place(lastLiveTime, true);
143
+ });
144
+ const unsub = liveTime.subscribe((t) => {
145
+ lastLiveTime = t;
146
+ if (!place(t, !usePlayerStore.getState().isPlaying)) return;
147
+ const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current;
133
148
  const scroll = scrollRef.current;
134
149
  if (
135
150
  !scroll ||
@@ -150,7 +165,10 @@ export function useTimelinePlayhead({
150
165
  scroll.scrollLeft = nextScrollLeft;
151
166
  }
152
167
  });
153
- return unsub;
168
+ return () => {
169
+ unsub();
170
+ unsubPlaying();
171
+ };
154
172
  });
155
173
 
156
174
  const seekFromX = useCallback(
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { timelineElementsChanged } from "./timelinePlayerSync";
3
+
4
+ describe("timelineElementsChanged", () => {
5
+ const clip = { id: "v", tag: "video", start: 0, duration: 4, track: 1 };
6
+ it.each([
7
+ ["muted", { muted: true }],
8
+ ["sound", { hasAudio: true }],
9
+ ["volume", { volume: 0 }],
10
+ ["speed", { playbackRate: 2 }],
11
+ ["hidden", { hidden: true }],
12
+ ["audio group volume", { audioGroupVolume: 0 }],
13
+ ["audio group hidden", { audioGroupHidden: true }],
14
+ ["fade in", { fadeIn: 1 }],
15
+ ["fade out", { fadeOut: 1 }],
16
+ ])("sees a clip whose %s changed with no timing change", (_name, change) => {
17
+ expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
18
+ expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
19
+ });
20
+ });
@@ -1,5 +1,22 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
 
3
+ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
4
+ "id",
5
+ "start",
6
+ "duration",
7
+ "track",
8
+ "sourceDuration",
9
+ "muted",
10
+ "hasAudio",
11
+ "volume",
12
+ "playbackRate",
13
+ "hidden",
14
+ "audioGroupVolume",
15
+ "audioGroupHidden",
16
+ "fadeIn",
17
+ "fadeOut",
18
+ ];
19
+
3
20
  /** Whether a derived timeline changes any field that affects rendering. */
4
21
  export function timelineElementsChanged(
5
22
  previous: TimelineElement[],
@@ -8,13 +25,6 @@ export function timelineElementsChanged(
8
25
  if (next.length !== previous.length) return true;
9
26
  return next.some((element, index) => {
10
27
  const prior = previous[index];
11
- return (
12
- !prior ||
13
- element.id !== prior.id ||
14
- element.start !== prior.start ||
15
- element.duration !== prior.duration ||
16
- element.track !== prior.track ||
17
- element.sourceDuration !== prior.sourceDuration
18
- );
28
+ return !prior || RENDERED_FIELDS.some((key) => element[key] !== prior[key]);
19
29
  });
20
30
  }
@@ -12,7 +12,7 @@ import {
12
12
  type PreviewIframeSlot,
13
13
  type UseTimelineSyncCallbacksParams,
14
14
  } from "./useTimelineSyncCallbacks";
15
- import type { PlaybackAdapter } from "../lib/playbackTypes";
15
+ import type { IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
16
16
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
17
17
  import { usePlayerStore } from "../store/playerStore";
18
18
 
@@ -23,6 +23,11 @@ export const SHADOW_READY_TIMEOUT_MS = 15_000;
23
23
  // A busy machine can need more than one budget; the shadow keeps loading for this many before it is dropped.
24
24
  export const SHADOW_READY_BUDGETS = 3;
25
25
 
26
+ function restoreSeekPainted(iframe: HTMLIFrameElement | null): Promise<void> | undefined {
27
+ const win = iframe?.contentWindow as IframeWindow | null | undefined;
28
+ return win?.__hfWaitForSeekCompletion?.().catch(() => {});
29
+ }
30
+
26
31
  function isDocumentHidden(): boolean {
27
32
  return typeof document !== "undefined" && document.visibilityState === "hidden";
28
33
  }
@@ -136,10 +141,16 @@ export function useShadowPreviewReload({
136
141
  const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
137
142
  const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
138
143
  const markAdapterReady = useCallback(
139
- (_iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
144
+ (iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
140
145
  if (gen == null || gen !== shadowGenRef.current) return;
141
- pendingCommitRef.current = { gen, commit };
142
- promoteWhenReady(gen);
146
+ const register = () => {
147
+ if (gen !== shadowGenRef.current) return;
148
+ pendingCommitRef.current = { gen, commit };
149
+ promoteWhenReady(gen);
150
+ };
151
+ const painted = restoreSeekPainted(iframe);
152
+ if (painted) void painted.then(register);
153
+ else register();
143
154
  },
144
155
  [promoteWhenReady],
145
156
  );
@@ -325,6 +325,25 @@ describe("shadow reload readiness and failure", () => {
325
325
  unmount(root);
326
326
  });
327
327
 
328
+ it("promotes only once the shadow's restore seek has painted its frame", async () => {
329
+ const { getApi, live, gen, root } = beginReload();
330
+ const shadow = makeShadowWithSpies();
331
+ let seekPainted = () => {};
332
+ Object.assign(shadow.iframe.contentWindow!, {
333
+ __hfWaitForSeekCompletion: () => new Promise<void>((resolve) => (seekPainted = resolve)),
334
+ });
335
+ act(() => {
336
+ getApi().setShadowIframeNode(shadow.iframe);
337
+ getApi().onShadowIframeLoad(gen);
338
+ getApi().onShadowReadyChange(gen, true);
339
+ });
340
+ expect(getApi().iframeRef.current).toBe(live);
341
+
342
+ await act(async () => seekPainted());
343
+ expect(getApi().iframeRef.current).toBe(shadow.iframe);
344
+ unmount(root);
345
+ });
346
+
328
347
  it("promotes when the loader clears before the adapter is ready", () => {
329
348
  const { getApi, gen, root } = beginReload();
330
349
  const shadow = makeShadowWithSpies();