@hyperframes/studio 0.8.83 → 0.8.85

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 (32) hide show
  1. package/dist/assets/{hyperframes-player-BAerMCex.js → hyperframes-player-BK18SggX.js} +3 -3
  2. package/dist/assets/{index-BUhcVhNy.js → index-Bus1BKtk.js} +1 -1
  3. package/dist/assets/{index-Iv0HI7eM.js → index-CZxhjQpw.js} +1 -1
  4. package/dist/assets/{index-BYhK_KMc.js → index-ClLdmgua.js} +153 -153
  5. package/dist/assets/{index-BWJqxeaH.css → index-mzZCha_N.css} +1 -1
  6. package/dist/{chunk-LPZMAGS7.js → chunk-7VDQIGOU.js} +7 -7
  7. package/dist/chunk-7VDQIGOU.js.map +1 -0
  8. package/dist/index.d.ts +21 -1
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +34 -20
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/editor/previewReadOnly.overlay.test.tsx +16 -2
  16. package/src/components/nle/CompositionBreadcrumb.tsx +5 -5
  17. package/src/compositionStackExport.test.ts +7 -0
  18. package/src/hooks/useDomEditCommits.ts +1 -0
  19. package/src/hooks/useDomEditTextCommits.test.tsx +36 -0
  20. package/src/hooks/useDomEditTextCommits.ts +8 -0
  21. package/src/hooks/useInlineTextEdit.test.tsx +39 -0
  22. package/src/hooks/useInlineTextEdit.ts +5 -9
  23. package/src/index.ts +1 -0
  24. package/src/player/components/TimelineLanes.tsx +2 -3
  25. package/src/player/components/timelineKeyboardNavigation.ts +2 -0
  26. package/src/player/components/useTimelineKeyboardActor.test.tsx +24 -1
  27. package/src/player/components/useTimelineKeyboardActor.ts +10 -2
  28. package/src/player/hooks/timelinePlayerSync.test.ts +1 -0
  29. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  30. package/src/player/lib/timelineDOM.test.ts +41 -7
  31. package/src/player/lib/timelineElementHelpers.ts +7 -7
  32. package/dist/chunk-LPZMAGS7.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.83",
3
+ "version": "0.8.85",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.83",
64
- "@hyperframes/player": "0.8.83",
65
- "@hyperframes/sdk": "0.8.83",
66
- "@hyperframes/parsers": "0.8.83",
67
- "@hyperframes/studio-server": "0.8.83",
68
- "@hyperframes/shader-transitions": "0.8.83"
63
+ "@hyperframes/core": "0.8.85",
64
+ "@hyperframes/player": "0.8.85",
65
+ "@hyperframes/parsers": "0.8.85",
66
+ "@hyperframes/shader-transitions": "0.8.85",
67
+ "@hyperframes/studio-server": "0.8.85",
68
+ "@hyperframes/sdk": "0.8.85"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.83"
84
+ "@hyperframes/producer": "0.8.85"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -20,6 +20,11 @@ const layout = vi.hoisted(() => ({
20
20
  offCanvasElements: new Map<string, HTMLElement>(),
21
21
  }));
22
22
 
23
+ const actions = vi.hoisted(() => ({ current: null as unknown }));
24
+ vi.mock("../../contexts/DomEditContext", async (importOriginal) => ({
25
+ ...(await importOriginal<typeof import("../../contexts/DomEditContext")>()),
26
+ useDomEditActionsContextOptional: () => actions.current,
27
+ }));
23
28
  vi.mock("./useOffCanvasIndicators", () => ({
24
29
  useOffCanvasIndicators: () => ({
25
30
  offCanvasRects: layout.offCanvas,
@@ -141,6 +146,7 @@ beforeEach(() => {
141
146
 
142
147
  afterEach(() => {
143
148
  act(() => root.unmount());
149
+ actions.current = null;
144
150
  document.body.innerHTML = "";
145
151
  layout.group = [];
146
152
  layout.hover = null;
@@ -454,10 +460,18 @@ describe("DomEditOverlay onTextEditingChange", () => {
454
460
  expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
455
461
  });
456
462
 
457
- it("reports false when the overlay unmounts mid-edit", () => {
458
- const { spies, overlay } = fixture();
463
+ it("saves the typed words and reports false when the overlay unmounts mid-edit", () => {
464
+ const handleDomRichTextCommit = vi.fn();
465
+ actions.current = { handleDomRichTextCommit };
466
+ const { spies, selection, overlay } = fixture();
459
467
  pressEnter(overlay);
468
+ selection.element.textContent = "Typed";
460
469
  act(() => root.unmount());
470
+ expect(handleDomRichTextCommit).toHaveBeenCalledWith({
471
+ element: selection.element,
472
+ html: "Typed",
473
+ previousHtml: "Title",
474
+ });
461
475
  expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
462
476
  });
463
477
  });
@@ -21,7 +21,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
21
21
  return (
22
22
  <nav
23
23
  aria-label="Composition navigation"
24
- className="flex items-center gap-1 px-2 h-8 border-b border-neutral-800/50 bg-neutral-900/50 shrink-0"
24
+ className="flex items-center gap-1 px-2 h-8 border-b border-hairline bg-bg-1 shrink-0"
25
25
  >
26
26
  {/* Back button — always goes to parent */}
27
27
  <button
@@ -33,7 +33,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
33
33
  });
34
34
  onNavigate(stack.length - 2);
35
35
  }}
36
- className="flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-xs text-neutral-400 hover:text-white hover:bg-neutral-800 active:scale-[0.98] transition-colors"
36
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-xs text-text-3 hover:text-text-0 hover:bg-hover active:scale-[0.98] transition-colors"
37
37
  title="Back (Esc, or double-click empty timeline)"
38
38
  aria-label="Back to parent composition"
39
39
  >
@@ -45,9 +45,9 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
45
45
  const isLast = i === stack.length - 1;
46
46
  return (
47
47
  <span key={level.id} className="flex items-center gap-1">
48
- {i > 0 && <CaretRight size={10} className="text-neutral-600 shrink-0" />}
48
+ {i > 0 && <CaretRight size={10} className="text-text-4 shrink-0" />}
49
49
  {isLast ? (
50
- <span className="text-xs text-neutral-200 font-medium">{level.label}</span>
50
+ <span className="text-xs text-text-1 font-medium">{level.label}</span>
51
51
  ) : (
52
52
  <button
53
53
  type="button"
@@ -55,7 +55,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
55
55
  trackStudioEvent("navigation", { action: "breadcrumb", target: level.label });
56
56
  onNavigate(i);
57
57
  }}
58
- className="text-xs text-neutral-500 hover:text-neutral-200 transition-colors"
58
+ className="text-xs text-text-3 hover:text-text-1 transition-colors"
59
59
  >
60
60
  {level.label}
61
61
  </button>
@@ -0,0 +1,7 @@
1
+ import { expect, it } from "vitest";
2
+ import { CompositionBreadcrumb, useCompositionStack } from "@hyperframes/studio";
3
+
4
+ it("exports the composition stack hook beside the breadcrumb it feeds", () => {
5
+ expect(typeof useCompositionStack).toBe("function");
6
+ expect(typeof CompositionBreadcrumb).toBe("function");
7
+ });
@@ -411,6 +411,7 @@ export function useDomEditCommits({
411
411
  handleDomAddTextField,
412
412
  handleDomRemoveTextField,
413
413
  } = useDomEditTextCommits({
414
+ readOnlyPreview,
414
415
  activeCompPath,
415
416
  previewIframeRef,
416
417
  domEditSelection,
@@ -98,6 +98,7 @@ function commitParams(
98
98
  buildDomSelectionFromTarget: vi.fn(async () => null),
99
99
  persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
100
100
  resolveImportedFontAsset: () => null,
101
+ readOnlyPreview: false,
101
102
  ...overrides,
102
103
  };
103
104
  }
@@ -124,6 +125,41 @@ afterEach(() => {
124
125
  });
125
126
 
126
127
  describe("useDomEditTextCommits", () => {
128
+ function richTextProbe() {
129
+ const { iframe, element } = previewElement('<h1 id="t">Old</h1>', "t");
130
+ const persist = vi.fn().mockResolvedValue(undefined);
131
+ const base = commitParams({
132
+ previewIframeRef: { current: iframe },
133
+ domEditSelection: selectionFor(element),
134
+ persistDomEditOperations: persist,
135
+ });
136
+ const captured: { hook: ReturnType<typeof useDomEditTextCommits> | null } = { hook: null };
137
+ function Probe({ readOnlyPreview }: { readOnlyPreview: boolean }) {
138
+ captured.hook = useDomEditTextCommits({ ...base, readOnlyPreview });
139
+ return null;
140
+ }
141
+ const root = mountReactHarness(<Probe readOnlyPreview={false} />);
142
+ cleanup = () => act(() => root.unmount());
143
+ const save = captured.hook!.handleDomRichTextCommit;
144
+ const commit = { element, html: "New", previousHtml: "Old" };
145
+ element.innerHTML = "New";
146
+ return { root, Probe, persist, element, save: () => act(async () => save(commit)) };
147
+ }
148
+
149
+ it("saves in-place text while the preview is editable", async () => {
150
+ const { persist, save } = richTextProbe();
151
+ await save();
152
+ expect(persist).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ it("refuses in-place text once the preview turns read-only, through an earlier handler, and puts the old text back", async () => {
156
+ const { root, Probe, persist, element, save } = richTextProbe();
157
+ act(() => root.render(<Probe readOnlyPreview />));
158
+ await save();
159
+ expect(persist).not.toHaveBeenCalled();
160
+ expect(element.innerHTML).toBe("Old");
161
+ });
162
+
127
163
  it("keeps concurrent text commit ownership isolated by target", async () => {
128
164
  const { iframe, element: firstElement } = previewElement(
129
165
  "<div id='first'>First</div><div id='second'>Second</div>",
@@ -53,6 +53,7 @@ export interface UseDomEditTextCommitsParams {
53
53
  ) => Promise<DomEditSelection | null>;
54
54
  persistDomEditOperations: PersistDomEditOperations;
55
55
  resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
56
+ readOnlyPreview: boolean;
56
57
  }
57
58
 
58
59
  function canCommitInlineTextSelection(selection: DomEditSelection, element: HTMLElement): boolean {
@@ -114,7 +115,10 @@ export function useDomEditTextCommits({
114
115
  buildDomSelectionFromTarget,
115
116
  persistDomEditOperations,
116
117
  resolveImportedFontAsset,
118
+ readOnlyPreview,
117
119
  }: UseDomEditTextCommitsParams) {
120
+ const latestReadOnlyPreviewRef = useRef(readOnlyPreview);
121
+ latestReadOnlyPreviewRef.current = readOnlyPreview;
118
122
  const domTextCommitVersionRef = useRef(new Map<string, symbol>());
119
123
  const domStyleCommitVersionRef = useRef(new Map<string, symbol>());
120
124
 
@@ -319,6 +323,10 @@ export function useDomEditTextCommits({
319
323
  const handleDomRichTextCommit = useCallback(
320
324
  async ({ element, html, previousHtml }: InlineTextEditCommit) => {
321
325
  if (!domEditSelection) return;
326
+ if (latestReadOnlyPreviewRef.current) {
327
+ if (element.isConnected && element.innerHTML === html) element.innerHTML = previousHtml;
328
+ return;
329
+ }
322
330
  // The same gate that let the edit open, not the design panel's.
323
331
  //
324
332
  // The panel's rule is about its text fields, and it has none for an
@@ -170,6 +170,45 @@ describe("useInlineTextEdit", () => {
170
170
  expect(element.hasAttribute("contenteditable")).toBe(false);
171
171
  });
172
172
 
173
+ it("saves an open edit when the editor unmounts", () => {
174
+ const element = heading();
175
+ const { controls, root, onCommit } = mount();
176
+
177
+ act(() => {
178
+ controls().start(element);
179
+ });
180
+ element.textContent = "Motion Playground Live";
181
+ act(() => root.unmount());
182
+
183
+ expect(onCommit.mock.calls).toEqual([
184
+ [{ element, html: "Motion Playground Live", previousHtml: "Motion Playground" }],
185
+ ]);
186
+ expect(element.hasAttribute("contenteditable")).toBe(false);
187
+ });
188
+
189
+ it("keeps the edit open when the commit function changes", () => {
190
+ const element = heading();
191
+ const controls: { current: InlineTextEditControls | null } = { current: null };
192
+ function Probe({ onCommit }: { onCommit: () => void }) {
193
+ controls.current = useInlineTextEdit({ onCommit });
194
+ return null;
195
+ }
196
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
197
+ const first = vi.fn();
198
+ const second = vi.fn();
199
+ act(() => root.render(<Probe onCommit={first} />));
200
+ act(() => {
201
+ controls.current!.start(element);
202
+ });
203
+ act(() => root.render(<Probe onCommit={second} />));
204
+
205
+ expect(controls.current!.session?.element).toBe(element);
206
+ expect(first).not.toHaveBeenCalled();
207
+ act(() => root.unmount());
208
+ expect(first).not.toHaveBeenCalled();
209
+ expect(second).toHaveBeenCalledTimes(1);
210
+ });
211
+
173
212
  it("hands the current text over exactly once when it commits", () => {
174
213
  const element = heading();
175
214
  const { controls, root, onCommit } = mount();
@@ -245,15 +245,11 @@ export function useInlineTextEdit({
245
245
  };
246
246
  }, [session, commit, cancel]);
247
247
 
248
- // Navigation can remove the overlay while the opening frame is pending.
249
- // Teardown is the single owner of cancelling that frame and restoring the
250
- // composition node, so unmount closes through the same path as every exit.
251
- useEffect(
252
- () => () => {
253
- teardown();
254
- },
255
- [teardown],
256
- );
248
+ // Unmount keeps the typed words, as blur does. Read through a ref so a new
249
+ // onCommit never closes the session mid-edit.
250
+ const commitRef = useRef(commit);
251
+ commitRef.current = commit;
252
+ useEffect(() => () => commitRef.current(), []);
257
253
 
258
254
  return { session, start, commit, cancel };
259
255
  }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
9
9
  export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
10
10
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
11
11
  export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
12
+ export { useCompositionStack } from "./components/nle/useCompositionStack";
12
13
 
13
14
  // Player (preview, timeline, playback controls)
14
15
  export {
@@ -134,9 +134,8 @@ export function TimelineLanes({
134
134
  focusedTargetId,
135
135
  rowGeometry,
136
136
  scrollRef,
137
- onToggleRow: (row) => {
138
- if (row.elementId) toggleClipExpandedTracked(row.elementId);
139
- },
137
+ onToggleRow: (row) => row.elementId && toggleClipExpandedTracked(row.elementId),
138
+ onDrillDown,
140
139
  });
141
140
  return (
142
141
  <div
@@ -49,6 +49,7 @@ export interface TimelineLogicalItem {
49
49
  elementId: string;
50
50
  /** The item's time anchor. Clips use their midpoint; ease controls use the segment midpoint. */
51
51
  time: number;
52
+ element?: TimelineElement;
52
53
  keyframeTarget?: TimelineKeyframeTarget;
53
54
  }
54
55
 
@@ -113,6 +114,7 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
113
114
  rowId,
114
115
  elementId: id,
115
116
  time: element.start + element.duration / 2,
117
+ element,
116
118
  };
117
119
  });
118
120
  }
@@ -3,7 +3,7 @@
3
3
  import React, { act, useRef } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
- import { usePlayerStore } from "../store/playerStore";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
7
  import { createTimelineRowGeometry } from "./timelineLayout";
8
8
  import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
9
9
  import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
@@ -57,6 +57,7 @@ interface HarnessProps {
57
57
  logicalRows?: readonly TimelineLogicalRow[];
58
58
  rowHeights?: readonly number[];
59
59
  onToggleRow?: (target: TimelineLogicalRow) => void;
60
+ onDrillDown?: (element: TimelineElement) => void;
60
61
  }
61
62
 
62
63
  function Harness({
@@ -64,6 +65,7 @@ function Harness({
64
65
  logicalRows = rows,
65
66
  rowHeights = logicalRows.map(() => 48),
66
67
  onToggleRow = vi.fn(),
68
+ onDrillDown,
67
69
  }: HarnessProps) {
68
70
  const scrollRef = useRef<HTMLDivElement>(null);
69
71
  const keyboard = useTimelineKeyboardActor({
@@ -75,6 +77,7 @@ function Harness({
75
77
  ),
76
78
  scrollRef,
77
79
  onToggleRow,
80
+ onDrillDown,
78
81
  });
79
82
  return (
80
83
  <div ref={scrollRef} onFocus={keyboard.onFocus} onKeyDown={keyboard.onKeyDown}>
@@ -228,6 +231,26 @@ describe("useTimelineKeyboardActor", () => {
228
231
  act(() => root.unmount());
229
232
  });
230
233
 
234
+ it("drills into a composition clip on Enter and leaves a plain clip alone", () => {
235
+ const scene = { id: "scene", tag: "div", start: 0, duration: 2, track: 1 };
236
+ const withElements = rows.map((row, index) => ({
237
+ ...row,
238
+ items: row.items.map((item) => ({
239
+ ...item,
240
+ element: index === 0 ? { ...scene, compositionSrc: "scene.html" } : scene,
241
+ })),
242
+ }));
243
+ const onDrillDown = vi.fn();
244
+ const { host, root } = renderHarness({ logicalRows: withElements, onDrillDown });
245
+ const composition = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-1"]')!;
246
+ const plain = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-2"]')!;
247
+ expect(key(plain, "Enter").defaultPrevented).toBe(false);
248
+ expect(onDrillDown).not.toHaveBeenCalled();
249
+ expect(key(composition, "Enter").defaultPrevented).toBe(true);
250
+ expect(onDrillDown).toHaveBeenCalledWith(withElements[0]!.items[0]!.element);
251
+ act(() => root.unmount());
252
+ });
253
+
231
254
  it("collapses expanded rows and leaves native header controls to the browser", () => {
232
255
  const onToggleRow = vi.fn();
233
256
  const expandedRows = [{ ...rows[0]!, expanded: true }, ...rows.slice(1)];
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
2
- import { usePlayerStore } from "../store/playerStore";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { TimelineRowGeometry } from "./timelineLayout";
4
4
  import {
5
5
  isTimelineNavigationKey,
@@ -14,6 +14,7 @@ interface TimelineKeyboardActorInput {
14
14
  rowGeometry: TimelineRowGeometry;
15
15
  scrollRef: RefObject<HTMLDivElement | null>;
16
16
  onToggleRow: (target: TimelineLogicalRow) => void;
17
+ onDrillDown?: (element: TimelineElement) => void;
17
18
  }
18
19
 
19
20
  function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
@@ -70,6 +71,7 @@ export function useTimelineKeyboardActor({
70
71
  rowGeometry,
71
72
  scrollRef,
72
73
  onToggleRow,
74
+ onDrillDown,
73
75
  }: TimelineKeyboardActorInput) {
74
76
  const rovingTargetId =
75
77
  (focusedTargetId && locateTimelineLogicalTarget(logicalRows, focusedTargetId)?.target.id) ??
@@ -130,6 +132,12 @@ export function useTimelineKeyboardActor({
130
132
  openContextMenu(targetElement);
131
133
  return;
132
134
  }
135
+ const composition = located.target.kind === "clip" && located.target.element;
136
+ if (event.key === "Enter" && composition && composition.compositionSrc && onDrillDown) {
137
+ event.preventDefault();
138
+ onDrillDown(composition);
139
+ return;
140
+ }
133
141
  if (
134
142
  (event.key !== "Enter" && event.key !== " ") ||
135
143
  located.target.kind !== "row" ||
@@ -140,7 +148,7 @@ export function useTimelineKeyboardActor({
140
148
  event.preventDefault();
141
149
  onToggleRow(located.target);
142
150
  },
143
- [logicalRowCountByTrack, logicalRows, onToggleRow, rowGeometry, scrollRef],
151
+ [logicalRowCountByTrack, logicalRows, onDrillDown, onToggleRow, rowGeometry, scrollRef],
144
152
  );
145
153
 
146
154
  return { rovingTargetId, onFocus, onKeyDown };
@@ -13,6 +13,7 @@ describe("timelineElementsChanged", () => {
13
13
  ["audio group hidden", { audioGroupHidden: true }],
14
14
  ["fade in", { fadeIn: 1 }],
15
15
  ["fade out", { fadeOut: 1 }],
16
+ ["source (re-pointed at its preview copy)", { src: "clip1.mp4?hf-proxy=h264" }],
16
17
  ])("sees a clip whose %s changed with no timing change", (_name, change) => {
17
18
  expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
18
19
  expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
@@ -15,6 +15,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
15
15
  "audioGroupHidden",
16
16
  "fadeIn",
17
17
  "fadeOut",
18
+ "src",
18
19
  ];
19
20
 
20
21
  /** Whether a derived timeline changes any field that affects rendering. */
@@ -9,7 +9,11 @@ import {
9
9
  import { isTimelineIgnoredElement } from "./timelineElementHelpers";
10
10
  import { computeResizePreview } from "../components/timelineClipDragPreview";
11
11
  import type { TimelineElement } from "../store/playerStore";
12
- import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
12
+ import {
13
+ MAX_PLAYBACK_RATE,
14
+ MIN_PLAYBACK_RATE,
15
+ readMediaOffsetSeconds,
16
+ } from "@hyperframes/parsers/media-duration";
13
17
 
14
18
  function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
15
19
  return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
@@ -269,19 +273,49 @@ describe("group info cache", () => {
269
273
 
270
274
  describe("parseTimelineFromDOM — canonical playback rate", () => {
271
275
  it.each([
272
- ["10", 5],
273
- ["0.01", 0.1],
274
- ])("clamps authored rate %s to %s for trim and split math", (authored, expected) => {
275
- const doc = makeDoc(`
276
+ ["8", 8],
277
+ ["10", MAX_PLAYBACK_RATE],
278
+ ["50", MAX_PLAYBACK_RATE],
279
+ ["0.01", MIN_PLAYBACK_RATE],
280
+ ])(
281
+ "clamps authored rate %s to %s, as playback does, for trim and split math",
282
+ (authored, expected) => {
283
+ const doc = makeDoc(`
276
284
  <div data-composition-id="root">
277
285
  <div id="nested" class="clip" data-composition-src="scene.html"
278
286
  data-start="0" data-duration="5" data-playback-rate="${authored}"></div>
279
287
  </div>
280
288
  `);
281
289
 
282
- const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
290
+ const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
291
+
292
+ expect(nested?.playbackRate).toBe(expected);
293
+ },
294
+ );
295
+ });
283
296
 
284
- expect(nested?.playbackRate).toBe(expected);
297
+ describe("parseTimelineFromDOM — head trim at an authored speed above 5x", () => {
298
+ it("moves the in-point by the speed playback uses", () => {
299
+ const doc = makeDoc(`
300
+ <div data-composition-id="root">
301
+ <video id="v" class="clip" data-start="2" data-duration="4" data-media-start="1"
302
+ data-playback-rate="8"></video>
303
+ </div>
304
+ `);
305
+ const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
306
+ const preview = computeResizePreview(
307
+ {
308
+ element,
309
+ edge: "start",
310
+ originClientX: 0,
311
+ previewStart: 2,
312
+ previewDuration: 4,
313
+ started: true,
314
+ },
315
+ 100,
316
+ { scroll: null, pps: 100, buildSnapTargets: () => [] },
317
+ );
318
+ expect([preview.previewStart, preview.previewPlaybackStart]).toEqual([3, 9]);
285
319
  });
286
320
  });
287
321
 
@@ -13,7 +13,11 @@ import { isFinitePositive } from "./playbackAdapter";
13
13
  import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
14
14
  import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
15
15
  import { readElementFades } from "@hyperframes/core/audio-fade";
16
- import { type AttrReader, readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
16
+ import {
17
+ type AttrReader,
18
+ clampPlaybackRate,
19
+ readMediaOffsetSeconds,
20
+ } from "@hyperframes/parsers/media-duration";
17
21
 
18
22
  // ---------------------------------------------------------------------------
19
23
  // Layer-reveal lift transparency
@@ -79,10 +83,6 @@ function readDurationAttribute(el: Element | null | undefined): number {
79
83
  return isFinitePositive(duration) ? duration : 0;
80
84
  }
81
85
 
82
- function normalizePlaybackRate(raw: number): number {
83
- return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
84
- }
85
-
86
86
  export function isTimelineIgnoredElement(el: Element): boolean {
87
87
  // An `<hf-audio-group>` is a mixer bus with no timing of its own, drawn as a group row, never a clip.
88
88
  if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
@@ -199,7 +199,7 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
199
199
 
200
200
  const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
201
201
  if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
202
- entry.playbackRate = normalizePlaybackRate(authoredPlaybackRate);
202
+ entry.playbackRate = clampPlaybackRate(authoredPlaybackRate);
203
203
  }
204
204
  }
205
205
 
@@ -262,7 +262,7 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen
262
262
 
263
263
  const playbackRate = mediaEl.defaultPlaybackRate;
264
264
  if (entry.playbackRate == null && Number.isFinite(playbackRate) && playbackRate > 0) {
265
- entry.playbackRate = normalizePlaybackRate(playbackRate);
265
+ entry.playbackRate = clampPlaybackRate(playbackRate);
266
266
  }
267
267
  }
268
268