@hyperframes/studio 0.8.79 → 0.8.81

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 (86) hide show
  1. package/dist/assets/hyperframes-player-C68UawR3.js +464 -0
  2. package/dist/assets/{index-ORZXYB0k.js → index-CMzPGv9P.js} +1 -1
  3. package/dist/assets/{index-CJP92F4-.js → index-CS5SImRT.js} +1 -1
  4. package/dist/assets/{index-DBKySIo7.js → index-hNVM9Pd2.js} +200 -197
  5. package/dist/index.d.ts +18 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1673 -1348
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +13 -10
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  13. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  14. package/src/components/editor/manualEdits.ts +1 -0
  15. package/src/components/editor/manualEditsParsing.ts +4 -0
  16. package/src/components/editor/useFxAudition.ts +3 -3
  17. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  18. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  19. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  20. package/src/components/panels/VariablesPanel.tsx +3 -3
  21. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  22. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  23. package/src/components/studioColorGradingScope.ts +5 -7
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/contexts/TimelineEditContext.tsx +2 -0
  26. package/src/hooks/liveLanes.ts +147 -0
  27. package/src/hooks/timelineAudioGroupVolume.ts +81 -47
  28. package/src/hooks/timelineEditPermission.ts +9 -1
  29. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  30. package/src/hooks/timelineEditingHelpers.ts +69 -43
  31. package/src/hooks/timelineElementFxAttribute.ts +82 -36
  32. package/src/hooks/useAppHotkeys.ts +1 -1
  33. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  34. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  35. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  36. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  37. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -4
  39. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  40. package/src/hooks/useDomEditSession.ts +1 -0
  41. package/src/hooks/useEditHistoryActions.ts +4 -3
  42. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  43. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  44. package/src/hooks/useLintModal.test.tsx +15 -0
  45. package/src/hooks/useLintModal.ts +5 -2
  46. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  47. package/src/hooks/useTimelineEditing.ts +19 -16
  48. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  49. package/src/hooks/useToast.ts +2 -1
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +23 -0
  52. package/src/player/components/Player.tsx +4 -2
  53. package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
  54. package/src/player/components/TimelineAutomationLane.tsx +18 -13
  55. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
  56. package/src/player/components/TimelineClipFades.test.tsx +24 -3
  57. package/src/player/components/TimelineClipFades.tsx +5 -2
  58. package/src/player/components/TimelineFxButton.tsx +1 -1
  59. package/src/player/components/TimelineGroupHeader.tsx +1 -1
  60. package/src/player/components/TimelineGroupRow.test.tsx +0 -1
  61. package/src/player/components/TimelineGroupRow.tsx +17 -11
  62. package/src/player/components/TimelineTrackHeader.tsx +21 -6
  63. package/src/player/components/groupAutomationElement.ts +9 -1
  64. package/src/player/components/timelineCallbacks.ts +2 -0
  65. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  66. package/src/player/components/timelineClipDragPreview.ts +15 -24
  67. package/src/player/components/timelineEditing.test.ts +99 -1
  68. package/src/player/components/timelineEditing.ts +5 -8
  69. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  70. package/src/player/components/timelineGroupEditing.ts +18 -15
  71. package/src/player/components/timelineStackingSync.ts +5 -5
  72. package/src/player/components/timelineZones.ts +2 -3
  73. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  74. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  75. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  76. package/src/player/components/useAutomationLanes.ts +58 -74
  77. package/src/player/lib/automationStoreSync.ts +30 -3
  78. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  79. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  80. package/src/player/lib/timelineDOM.test.ts +28 -8
  81. package/src/player/lib/timelineElementHelpers.ts +3 -4
  82. package/src/player/lib/timelineGroupInfo.ts +23 -23
  83. package/src/utils/studioFileHistory.ts +36 -30
  84. package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
  85. package/src/player/components/automationGestureKeys.test.ts +0 -49
  86. package/src/player/components/automationGestureKeys.ts +0 -49
@@ -5,15 +5,15 @@
5
5
  * Reading lives in `automationLaneData`, shared with the row layout, which needs
6
6
  * the lane count to reserve height.
7
7
  *
8
- * Edits go to the *selected* element, because that is what the attribute commit
9
- * path targets. An unselected clip still draws its envelopes — they are just
10
- * read only, which is also what stops a stray drag from editing the wrong track.
8
+ * Edits save through the timeline's gated save, as an effect preset does, so a
9
+ * locked clip or group refuses them. Only the selected element is editable: an
10
+ * unselected one still draws its envelopes read only, which is also what stops a
11
+ * stray drag from editing the wrong track.
11
12
  */
12
13
 
13
- import { useCallback, useMemo, useRef } from "react";
14
+ import { useCallback, useMemo } from "react";
14
15
  import {
15
16
  HF_AUDIO_AUTOMATION_ATTR,
16
- serializeAutomation,
17
17
  type HfAutomation,
18
18
  type HfAutomationLane,
19
19
  } from "@hyperframes/core/audio-automation";
@@ -22,22 +22,25 @@ import {
22
22
  useDomEditActionsContextOptional,
23
23
  useDomEditSelectionContextOptional,
24
24
  } from "../../contexts/DomEditContext";
25
- import { resolveTimelineIdForSelection } from "../../utils/studioHelpers";
25
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
26
+ import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
26
27
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
27
28
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
28
29
  import type { AutomationSelection } from "../store/automationSelectionSlice";
30
+ import { automationAttrValue } from "../../components/editor/propertyPanelAutomation";
29
31
  import { elementAutomation, elementFxChain } from "./automationLaneData";
30
- import { createAutomationGestureKeys } from "./automationGestureKeys";
32
+ import { isGroupAutomationElement } from "./groupAutomationElement";
33
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
31
34
 
32
35
  export interface AutomationLaneBinding {
33
36
  automation: HfAutomation;
34
37
  /** One entry per lane, in draw order — each gets its own row. */
35
38
  lanes: HfAutomationLane[];
36
39
  chain: HfAudioFxChain | null;
37
- /** Continuous write while dragging; does not persist. */
38
- onPreview(next: HfAutomation): void;
40
+ /** Continuous write while dragging; does not persist. `ended`: a cancel put the start back. */
41
+ onPreview(next: HfAutomation, ended?: boolean): void;
39
42
  /** Gesture-end write; this is the one that persists and lands in undo. */
40
- onCommit(next: HfAutomation): void;
43
+ onCommit(next: HfAutomation): Promise<TimelineEditOutcome | void>;
41
44
  /**
42
45
  * Select this clip, which is what makes its lanes editable. A lane calls this
43
46
  * instead of writing when it is read-only — pressing the lane is the only
@@ -46,15 +49,6 @@ export interface AutomationLaneBinding {
46
49
  */
47
50
  onSelect(): void;
48
51
  readOnly: boolean;
49
- /**
50
- * The timeline clip `onCommit`/`onPreview` will ACTUALLY persist to, which is
51
- * whatever the dom-edit layer has selected — not necessarily the element this
52
- * binding was made for (see `onSelect` below). Null outside an edit session or
53
- * when the dom-edit selection maps to no clip. Resolved exactly the way
54
- * applyDomSelection resolves it, so in a settled selection it equals the bound
55
- * element's key; it lags only in the window a non-gesture caller can hit.
56
- */
57
- commitTargetKey: string | null;
58
52
  /** This element's active selection box, or null if none / it belongs to a
59
53
  * different element. */
60
54
  selection: AutomationSelection | null;
@@ -69,80 +63,69 @@ export interface UseAutomationLanesResult {
69
63
  bind(element: TimelineElement, isSelected: boolean): AutomationLaneBinding;
70
64
  }
71
65
 
66
+ const AUTOMATION_LABEL = "Edit automation";
67
+
68
+ /** A group's lanes save on the group, gated by its members; a clip's on the clip. */
69
+ function automationWriters(edit: TimelineEditCallbacks, element: TimelineElement) {
70
+ if (isGroupAutomationElement(element)) {
71
+ const { onSetAudioGroupAttributeLive: live, onSetAudioGroupAttributeQuiet: save } = edit;
72
+ if (!live || !save) return null;
73
+ return {
74
+ live: (value: string | null) => live(element.id, HF_AUDIO_AUTOMATION_ATTR, value),
75
+ revert: () => edit.onRevertAudioGroupAttributeLive?.(element.id, HF_AUDIO_AUTOMATION_ATTR),
76
+ save: (value: string | null) =>
77
+ save(element.id, HF_AUDIO_AUTOMATION_ATTR, value, AUTOMATION_LABEL),
78
+ };
79
+ }
80
+ const { onSetElementAttributeLive: live, onSetElementAttributeQuiet: save } = edit;
81
+ if (!live || !save) return null;
82
+ return {
83
+ live: (value: string | null) => live(element, HF_AUDIO_AUTOMATION_ATTR, value),
84
+ revert: () => edit.onRevertElementAttributeLive?.(element, HF_AUDIO_AUTOMATION_ATTR),
85
+ save: (value: string | null) =>
86
+ save(element, HF_AUDIO_AUTOMATION_ATTR, value, AUTOMATION_LABEL),
87
+ };
88
+ }
89
+
72
90
  export function useAutomationLanes(): UseAutomationLanesResult {
73
- // One per hook instance, held across renders: a gesture spans many commits and
74
- // they all have to record under the same key for undo to take the whole drag.
75
- const gestureKeys = useRef(createAutomationGestureKeys());
76
91
  // Optional: the player also runs outside Studio, where there is no edit
77
92
  // session. There the lanes render read-only, which is the right fallback.
78
93
  const domEdit = useDomEditActionsContextOptional();
79
- const domEditSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
80
- const elements = usePlayerStore((s) => s.elements);
94
+ const domEditSelectionRef = useDomEditSelectionContextOptional()?.domEditSelectionRef;
95
+ const timelineEdit = useTimelineEditContextOptional();
81
96
  const automationSelection = usePlayerStore((s) => s.automationSelection);
82
97
  const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection);
83
98
  const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection);
84
99
 
85
- // Read from the SAME render as the commit handlers below: both contexts update
86
- // in one commit, so a handler and this key can never describe different
87
- // moments. activeCompPath is not needed — resolveTimelineIdForSelection only
88
- // uses it as a fallback for a selection with no sourceFile of its own, and
89
- // DomEditSelection always carries one.
90
- const commitTargetKey = useMemo(
91
- () =>
92
- domEditSelection ? resolveTimelineIdForSelection(domEditSelection, elements, null) : null,
93
- [domEditSelection, elements],
94
- );
95
-
96
100
  const bind = useCallback(
97
101
  (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => {
98
102
  const chain = elementFxChain(element);
99
103
  const automation = elementAutomation(element);
100
104
  const elementKey = getTimelineElementIdentity(element);
101
105
 
102
- const write = (next: HfAutomation, persist: boolean): void => {
103
- if (!domEdit || !isSelected) return;
104
- const value = next.lanes.length > 0 ? serializeAutomation(next) : "";
105
- // Every write of one gesture under one key, so undo takes back the whole
106
- // drag rather than the last fragment history happened to keep.
107
- const coalesce = persist ? gestureKeys.current.commit() : gestureKeys.current.live();
108
- // Quiet, not the refreshing commit: releasing a dragged point used to
109
- // reload the preview, which restarts every playing track — the same chop
110
- // the live write during the drag exists to avoid. Quiet still persists
111
- // and still resyncs the selection, so the next edit sees this one.
112
- if (persist) {
113
- void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value, coalesce);
114
- }
115
- // Dragging a point writes live: no preview refresh, so the composition
116
- // does not reload and restart playback on every pixel.
117
- else {
118
- // Preview only, because a gesture writes on every pointermove: the
119
- // preview and the running audio follow the pointer, and the release
120
- // below is the one write that reaches the file and the undo stack.
121
- void domEdit.handleDomAttributeLiveCommit(
122
- HF_AUDIO_AUTOMATION_ATTR,
123
- value || null,
124
- undefined,
125
- {
126
- coalesce,
127
- previewOnly: true,
128
- },
129
- );
130
- }
131
- };
106
+ const writers = domEdit && isSelected ? automationWriters(timelineEdit, element) : null;
132
107
 
133
108
  return {
134
109
  automation,
135
110
  lanes: automation.lanes,
136
111
  chain,
137
- onPreview: (next) => write(next, false),
138
- onCommit: (next) => write(next, true),
139
- // Deliberately not awaited before an edit: the commit handlers close
140
- // over the selection as it was when they were built, so writing in the
141
- // same tick would land on whichever element was selected before.
142
- // Selecting is its own gesture; the lane goes live after it.
112
+ onPreview: (next, ended) => {
113
+ writers?.live(automationAttrValue(next) || null);
114
+ if (ended) writers?.revert();
115
+ },
116
+ onCommit: async (next) => {
117
+ if (!writers) return;
118
+ const outcome = await writers.save(automationAttrValue(next) || null);
119
+ // The FX panel reads the selection snapshot; a stale one makes its next
120
+ // edit start from the automation this one replaced.
121
+ const selection = domEditSelectionRef?.current;
122
+ if (selection) void domEdit?.refreshDomEditSelectionFromPreview(selection);
123
+ return outcome;
124
+ },
125
+ // Deliberately not awaited before an edit: the lane goes live when the
126
+ // selection lands and it re-renders as selected.
143
127
  onSelect: () => void domEdit?.handleTimelineElementSelect(element),
144
- readOnly: !domEdit || !isSelected,
145
- commitTargetKey: domEdit ? commitTargetKey : null,
128
+ readOnly: !writers,
146
129
  selection: automationSelection?.elementKey === elementKey ? automationSelection : null,
147
130
  // Not gated on `isSelected`, unlike the writes above. A selection is
148
131
  // ephemeral store state, and the drag that draws one on a read-only lane is
@@ -159,7 +142,8 @@ export function useAutomationLanes(): UseAutomationLanesResult {
159
142
  },
160
143
  [
161
144
  domEdit,
162
- commitTargetKey,
145
+ domEditSelectionRef,
146
+ timelineEdit,
163
147
  automationSelection,
164
148
  setAutomationSelection,
165
149
  clearAutomationSelection,
@@ -6,8 +6,9 @@
6
6
  * an envelope afterwards writes to the preview document and the source file, and the
7
7
  * store would go on holding the value it was born with until a reload.
8
8
  *
9
- * One reader, called from the two places a change lands: the resync every dom-edit
10
- * attribute commit already runs, and the soft restore an undo or redo applies. It
9
+ * One reader, called where a change lands: the resync every dom-edit attribute
10
+ * commit runs, and an undo or redo's soft restore (timeline saves record their own
11
+ * value through syncStoredElementAttribute below). It
11
12
  * reads the preview rather than being told, because those callers know a file
12
13
  * changed, not which attribute — and because three separate writers shipped without
13
14
  * remembering to sync, which is what a single sink prevents.
@@ -17,7 +18,7 @@ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
17
18
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
18
19
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
19
20
  import { groupInfoFor } from "./timelineGroupInfo";
20
- import { previewElementFinder } from "./timelineElementHelpers";
21
+ import { getTimelineElementIdentity, previewElementFinder } from "./timelineElementHelpers";
21
22
 
22
23
  /**
23
24
  * Re-read every element's automation and FX-chain attributes from the preview
@@ -72,3 +73,29 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
72
73
  return changed ? { elements } : {};
73
74
  });
74
75
  }
76
+
77
+ const STORED_FIELD: Record<string, "automation" | "fxChain"> = {
78
+ [HF_AUDIO_AUTOMATION_ATTR]: "automation",
79
+ [HF_AUDIO_FX_ATTR]: "fxChain",
80
+ };
81
+
82
+ /** Record a saved automation or FX-chain value on one element's stored copy. */
83
+ export function syncStoredElementAttribute(
84
+ target: TimelineElement,
85
+ attr: string,
86
+ value: string | null,
87
+ ): void {
88
+ const field = STORED_FIELD[attr];
89
+ if (!field) return;
90
+ const key = getTimelineElementIdentity(target);
91
+ const next = value ?? undefined;
92
+ usePlayerStore.setState((state) => {
93
+ let changed = false;
94
+ const elements = state.elements.map((element) => {
95
+ if (getTimelineElementIdentity(element) !== key || element[field] === next) return element;
96
+ changed = true;
97
+ return { ...element, [field]: next };
98
+ });
99
+ return changed ? { elements } : {};
100
+ });
101
+ }
@@ -0,0 +1,82 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { decodeImageThumbnail } from "./thumbnailImageDecoder";
4
+
5
+ class TestImage {
6
+ static instances: TestImage[] = [];
7
+ naturalWidth = 180;
8
+ naturalHeight = 135;
9
+ src = "";
10
+ onload: (() => void) | null = null;
11
+ onerror: (() => void) | null = null;
12
+ constructor() {
13
+ TestImage.instances.push(this);
14
+ }
15
+ }
16
+ beforeEach(() => {
17
+ TestImage.instances = [];
18
+ vi.stubGlobal("Image", TestImage);
19
+ });
20
+ afterEach(() => vi.unstubAllGlobals());
21
+ function latest() {
22
+ const image = TestImage.instances.at(-1);
23
+ if (!image) throw new Error("missing image");
24
+ return image;
25
+ }
26
+
27
+ describe("timeline image thumbnails", () => {
28
+ it("requests a bounded local JPEG and accounts for its actual decoded dimensions", async () => {
29
+ const result = decodeImageThumbnail(
30
+ "/api/projects/p/preview/photo.jpg?v=2",
31
+ new AbortController().signal,
32
+ );
33
+ expect(latest().src).toBe(
34
+ new URL("/api/projects/p/image-thumbnail/photo.jpg?v=2", location.href).href,
35
+ );
36
+ latest().onload?.();
37
+ expect(await result).toEqual({
38
+ value: {
39
+ kind: "image",
40
+ url: new URL("/api/projects/p/image-thumbnail/photo.jpg?v=2", location.href).href,
41
+ aspect: 4 / 3,
42
+ },
43
+ weight: 180 * 135 * 4,
44
+ });
45
+ expect(latest().src).toBe("");
46
+ });
47
+ it.each([
48
+ "https://remote.test/photo.jpg",
49
+ "/api/projects/p/preview/image.png",
50
+ "/api/projects/p/preview/image.gif",
51
+ "/api/projects/p/preview/image.webp",
52
+ "/api/projects/p/preview/image.svg",
53
+ "/other/photo.jpg",
54
+ ])("preserves %s", async (source) => {
55
+ const result = decodeImageThumbnail(source, new AbortController().signal);
56
+ expect(latest().src).toBe(source);
57
+ latest().onload?.();
58
+ expect((await result).value).toMatchObject({ url: source });
59
+ });
60
+ it("falls back for an older server and accounts for original dimensions", async () => {
61
+ const result = decodeImageThumbnail(
62
+ "/api/projects/p/preview/photo.jpg",
63
+ new AbortController().signal,
64
+ );
65
+ latest().onerror?.();
66
+ await Promise.resolve();
67
+ expect(latest().src).toBe("/api/projects/p/preview/photo.jpg");
68
+ latest().naturalWidth = 2560;
69
+ latest().naturalHeight = 1920;
70
+ latest().onload?.();
71
+ expect((await result).weight).toBe(2560 * 1920 * 4);
72
+ });
73
+ it("does not retry a cancelled request", async () => {
74
+ const controller = new AbortController();
75
+ const result = decodeImageThumbnail("/api/projects/p/preview/photo.jpg", controller.signal);
76
+ controller.abort();
77
+ await expect(result).rejects.toMatchObject({ name: "AbortError" });
78
+ expect(TestImage.instances).toHaveLength(1);
79
+ expect(latest().src).toBe("");
80
+ expect(latest().onload).toBeNull();
81
+ });
82
+ });
@@ -0,0 +1,73 @@
1
+ import type { ThumbnailLoadedResult } from "./thumbnailScheduler";
2
+ import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
3
+
4
+ function thumbnailSource(source: string): string {
5
+ const url = new URL(source, window.location.href);
6
+ if (
7
+ url.origin !== window.location.origin ||
8
+ !/^\/api\/projects\/[^/]+\/preview\/.+\.jpe?g$/i.test(url.pathname)
9
+ )
10
+ return source;
11
+ url.pathname = url.pathname.replace(
12
+ /^(\/api\/projects\/[^/]+)\/preview\//,
13
+ "$1/image-thumbnail/",
14
+ );
15
+ return url.href;
16
+ }
17
+
18
+ function loadImage(source: string, signal: AbortSignal): Promise<HTMLImageElement> {
19
+ return new Promise((resolve, reject) => {
20
+ const image = new Image();
21
+ const cleanup = () => {
22
+ image.onload = null;
23
+ image.onerror = null;
24
+ signal.removeEventListener("abort", onAbort);
25
+ };
26
+ const onAbort = () => {
27
+ cleanup();
28
+ image.src = "";
29
+ reject(new DOMException("Aborted", "AbortError"));
30
+ };
31
+ if (signal.aborted) {
32
+ onAbort();
33
+ return;
34
+ }
35
+ image.onload = () => {
36
+ cleanup();
37
+ resolve(image);
38
+ };
39
+ image.onerror = () => {
40
+ cleanup();
41
+ if (/\.svg($|\?)/i.test(source)) resolve(image);
42
+ else {
43
+ image.src = "";
44
+ reject(new Error("Image thumbnail failed to load"));
45
+ }
46
+ };
47
+ signal.addEventListener("abort", onAbort, { once: true });
48
+ image.src = source;
49
+ });
50
+ }
51
+
52
+ export async function decodeImageThumbnail(
53
+ source: string,
54
+ signal: AbortSignal,
55
+ ): Promise<ThumbnailLoadedResult> {
56
+ let url = thumbnailSource(source);
57
+ let image: HTMLImageElement;
58
+ try {
59
+ image = await loadImage(url, signal);
60
+ } catch (error) {
61
+ if (signal.aborted || url === source) throw error;
62
+ url = source;
63
+ image = await loadImage(source, signal);
64
+ }
65
+ try {
66
+ signal.throwIfAborted();
67
+ const width = image.naturalWidth || TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth;
68
+ const height = image.naturalHeight || TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight;
69
+ return { value: { kind: "image", url, aspect: width / height }, weight: width * height * 4 };
70
+ } finally {
71
+ image.src = "";
72
+ }
73
+ }
@@ -8,7 +8,6 @@ import {
8
8
  } from "./timelineDOM";
9
9
  import { isTimelineIgnoredElement } from "./timelineElementHelpers";
10
10
  import { computeResizePreview } from "../components/timelineClipDragPreview";
11
- import { invalidateGroupInfoCache } from "./timelineGroupInfo";
12
11
  import type { TimelineElement } from "../store/playerStore";
13
12
 
14
13
  function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
@@ -21,6 +20,32 @@ function makeDoc(html: string): Document {
21
20
  return d;
22
21
  }
23
22
 
23
+ describe("parseTimelineFromDOM — media in-point", () => {
24
+ it("reads a negative in-point as 0, as the runtime does, so a head trim keeps the clip", () => {
25
+ const doc = makeDoc(`
26
+ <div data-composition-id="root">
27
+ <video id="v" class="clip" data-start="2" data-duration="1" data-media-start="-1"></video>
28
+ </div>
29
+ `);
30
+ const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
31
+ expect(element.playbackStart).toBe(0);
32
+
33
+ const preview = computeResizePreview(
34
+ {
35
+ element,
36
+ edge: "start",
37
+ originClientX: 0,
38
+ previewStart: 2,
39
+ previewDuration: 1,
40
+ started: true,
41
+ },
42
+ 10,
43
+ { scroll: null, pps: 100, buildSnapTargets: () => [] },
44
+ );
45
+ expect(preview.previewDuration).toBe(0.9);
46
+ });
47
+ });
48
+
24
49
  describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
25
50
  it("bridges a real GSAP transition marker onto both named clips", () => {
26
51
  document.body.innerHTML = `
@@ -166,7 +191,7 @@ describe("group info cache", () => {
166
191
  // changes either. Without an explicit drop, a muted group could never be
167
192
  // unmuted: the header kept reading the cached `hidden: false` and re-wrote
168
193
  // `data-hidden` forever.
169
- it("re-reads group state after an invalidation", () => {
194
+ it("re-reads group state written earlier in the same task", () => {
170
195
  const doc = makeDoc(`
171
196
  <div data-composition-id="root">
172
197
  <audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
@@ -175,15 +200,10 @@ describe("group info cache", () => {
175
200
  `);
176
201
 
177
202
  expect(parseMember(doc).audioGroupHidden).toBe(false);
178
-
203
+ // No await: the observer has not delivered either write when the next read runs.
179
204
  doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
180
- expect(parseMember(doc).audioGroupHidden).toBe(false); // still the cached scan
181
-
182
- invalidateGroupInfoCache(doc);
183
205
  expect(parseMember(doc).audioGroupHidden).toBe(true);
184
-
185
206
  doc.getElementById("voiceover")?.removeAttribute("data-hidden");
186
- invalidateGroupInfoCache(doc);
187
207
  expect(parseMember(doc).audioGroupHidden).toBe(false);
188
208
  });
189
209
 
@@ -13,6 +13,7 @@ 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 { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Layer-reveal lift transparency
@@ -171,10 +172,8 @@ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element):
171
172
  const playbackStartValue = el.getAttribute("data-playback-start");
172
173
  const legacyMediaStartValue = el.getAttribute("data-media-start");
173
174
  const mediaStartValue = playbackStartValue ?? legacyMediaStartValue;
174
- if (mediaStartValue != null) {
175
- const playbackStart = parseFloat(mediaStartValue);
176
- if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
177
- }
175
+ if (mediaStartValue != null)
176
+ entry.playbackStart = readMediaOffsetSeconds((n) => el.getAttribute(n));
178
177
  if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
179
178
  else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
180
179
 
@@ -42,7 +42,24 @@ const groupInfoCache = new WeakMap<
42
42
 
43
43
  /** Bumped by every observed mutation to group state in a document. */
44
44
  const groupRevisions = new WeakMap<Document, number>();
45
- const groupObservers = new WeakSet<Document>();
45
+ const groupObservers = new WeakMap<Document, MutationObserver>();
46
+
47
+ function countGroupChanges(doc: Document, records: readonly MutationRecord[]): void {
48
+ // Only a group element added or removed counts: childList fires for every node a playing
49
+ // preview inserts. Attribute records always count; the observer filter already narrowed them.
50
+ const relevant = records.some(
51
+ (record) =>
52
+ record.type !== "childList" ||
53
+ [...record.addedNodes, ...record.removedNodes].some(
54
+ (node) =>
55
+ node instanceof Element &&
56
+ (node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
57
+ node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
58
+ node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
59
+ ),
60
+ );
61
+ if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
62
+ }
46
63
 
47
64
  /**
48
65
  * Watch a document for any change to group state, so the cache expires itself.
@@ -55,28 +72,8 @@ const groupObservers = new WeakSet<Document>();
55
72
  */
56
73
  function observeGroupState(doc: Document): void {
57
74
  if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
58
- groupObservers.add(doc);
59
- const observer = new MutationObserver((records) => {
60
- // `childList` fires for EVERY node added or removed anywhere in the live
61
- // preview, which on a composition that churns nodes during playback
62
- // (SplitText, a typewriter, anything runtime-inserted) would expire this
63
- // cache permanently and put it back to one whole-tree scan per parse. Only
64
- // a group ELEMENT appearing or leaving actually changes the answer, so
65
- // childList records are filtered rather than trusted; attribute records
66
- // always count, because the filter below already narrowed them.
67
- const relevant = records.some(
68
- (record) =>
69
- record.type !== "childList" ||
70
- [...record.addedNodes, ...record.removedNodes].some(
71
- (node) =>
72
- node instanceof Element &&
73
- (node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
74
- node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
75
- node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
76
- ),
77
- );
78
- if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
79
- });
75
+ const observer = new MutationObserver((records) => countGroupChanges(doc, records));
76
+ groupObservers.set(doc, observer);
80
77
  observer.observe(doc.body, {
81
78
  subtree: true,
82
79
  childList: true,
@@ -112,6 +109,9 @@ export function invalidateGroupInfoCache(doc: Document | null | undefined): void
112
109
  export function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
113
110
  if (!doc) return { label: groupId, volume: 1, hidden: false };
114
111
  observeGroupState(doc);
112
+ // A write earlier in this task has not reached the observer's callback yet.
113
+ const unseen = groupObservers.get(doc)?.takeRecords();
114
+ if (unseen?.length) countGroupChanges(doc, unseen);
115
115
  const revision = groupRevisions.get(doc) ?? 0;
116
116
  const cached = groupInfoCache.get(doc);
117
117
  let info = cached && cached.revision === revision ? cached.entries : undefined;
@@ -60,7 +60,15 @@ export async function readProjectFileContent(pid: string, path: string): Promise
60
60
  return data.content;
61
61
  }
62
62
 
63
- export async function saveProjectFilesWithHistory({
63
+ export async function saveProjectFilesWithHistory(
64
+ input: SaveProjectFilesWithHistoryInput,
65
+ ): Promise<string[]> {
66
+ return serializeStudioFileMutations(input.writeFile, Object.keys(input.files), () =>
67
+ writeProjectFilesWithHistoryInQueue(input),
68
+ );
69
+ }
70
+
71
+ export async function writeProjectFilesWithHistoryInQueue({
64
72
  label,
65
73
  coalesceKey,
66
74
  coalesceMs,
@@ -70,39 +78,37 @@ export async function saveProjectFilesWithHistory({
70
78
  recordEdit,
71
79
  diskContent,
72
80
  }: SaveProjectFilesWithHistoryInput): Promise<string[]> {
73
- return serializeStudioFileMutations(writeFile, Object.keys(files), async () => {
74
- const snapshots: Record<string, { before: string; after: string }> = {};
75
- for (const [path, after] of Object.entries(files)) {
76
- const before = await readFile(path);
77
- if (before !== after) {
78
- snapshots[path] = { before, after };
79
- }
81
+ const snapshots: Record<string, { before: string; after: string }> = {};
82
+ for (const [path, after] of Object.entries(files)) {
83
+ const before = await readFile(path);
84
+ if (before !== after) {
85
+ snapshots[path] = { before, after };
80
86
  }
87
+ }
81
88
 
82
- const changedPaths = Object.keys(snapshots);
83
- if (changedPaths.length === 0) return [];
89
+ const changedPaths = Object.keys(snapshots);
90
+ if (changedPaths.length === 0) return [];
84
91
 
85
- const writtenPaths: string[] = [];
86
- try {
87
- for (const path of changedPaths) {
88
- await writeFile(path, snapshots[path].after, diskContent?.[path] ?? snapshots[path].before);
89
- writtenPaths.push(path);
90
- }
92
+ const writtenPaths: string[] = [];
93
+ try {
94
+ for (const path of changedPaths) {
95
+ await writeFile(path, snapshots[path].after, diskContent?.[path] ?? snapshots[path].before);
96
+ writtenPaths.push(path);
97
+ }
91
98
 
92
- await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });
93
- } catch (error) {
94
- try {
95
- for (const path of writtenPaths.reverse()) {
96
- await writeFile(path, snapshots[path].before, snapshots[path].after);
97
- }
98
- } catch (rollbackError) {
99
- throw new AggregateError(
100
- [error, rollbackError],
101
- "Failed to save project files and rollback did not complete",
102
- );
99
+ await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });
100
+ } catch (error) {
101
+ try {
102
+ for (const path of writtenPaths.reverse()) {
103
+ await writeFile(path, snapshots[path].before, snapshots[path].after);
103
104
  }
104
- throw error;
105
+ } catch (rollbackError) {
106
+ throw new AggregateError(
107
+ [error, rollbackError],
108
+ "Failed to save project files and rollback did not complete",
109
+ );
105
110
  }
106
- return changedPaths;
107
- });
111
+ throw error;
112
+ }
113
+ return changedPaths;
108
114
  }