@hyperframes/studio 0.8.78 → 0.8.80

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 (99) hide show
  1. package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
  2. package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
  3. package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
  4. package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
  5. package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
  6. package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
  7. package/dist/index.d.ts +40 -16
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1918 -1408
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -1
  13. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  14. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  15. package/src/components/editor/manualEdits.ts +1 -0
  16. package/src/components/editor/manualEditsParsing.ts +4 -0
  17. package/src/components/editor/useFxAudition.ts +3 -3
  18. package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
  19. package/src/contexts/DomEditContext.tsx +4 -0
  20. package/src/contexts/TimelineEditContext.tsx +2 -0
  21. package/src/hooks/liveLanes.ts +147 -0
  22. package/src/hooks/timelineAudioGroupVolume.ts +97 -53
  23. package/src/hooks/timelineEditPermission.ts +55 -13
  24. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  25. package/src/hooks/timelineEditingHelpers.ts +69 -43
  26. package/src/hooks/timelineElementFxAttribute.ts +92 -40
  27. package/src/hooks/useAppHotkeys.ts +1 -1
  28. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  29. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  30. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  31. package/src/hooks/useBlockHandlers.ts +7 -4
  32. package/src/hooks/useClipboard.source.test.ts +340 -0
  33. package/src/hooks/useClipboard.ts +141 -62
  34. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  35. package/src/hooks/useDomEditSession.ts +1 -0
  36. package/src/hooks/useEditHistoryActions.ts +4 -3
  37. package/src/hooks/useElementPicker.test.ts +172 -0
  38. package/src/hooks/useElementPicker.ts +141 -93
  39. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  40. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  41. package/src/hooks/useFrameCapture.test.tsx +67 -0
  42. package/src/hooks/useFrameCapture.ts +18 -9
  43. package/src/hooks/usePersistentEditHistory.test.ts +2 -2
  44. package/src/hooks/usePersistentEditHistory.ts +2 -2
  45. package/src/hooks/useTimelineEditing.test.tsx +59 -0
  46. package/src/hooks/useTimelineEditing.ts +59 -47
  47. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  48. package/src/hooks/useToast.ts +2 -1
  49. package/src/index.ts +1 -0
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +28 -0
  52. package/src/player/components/Player.tsx +5 -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/VideoThumbnail.test.tsx +13 -0
  64. package/src/player/components/VideoThumbnail.tsx +1 -1
  65. package/src/player/components/groupAutomationElement.ts +9 -1
  66. package/src/player/components/timelineCallbacks.ts +5 -2
  67. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  68. package/src/player/components/timelineClipDragPreview.ts +15 -24
  69. package/src/player/components/timelineEditing.test.ts +99 -1
  70. package/src/player/components/timelineEditing.ts +5 -8
  71. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  72. package/src/player/components/timelineGroupEditing.ts +18 -15
  73. package/src/player/components/timelineStackingSync.ts +5 -5
  74. package/src/player/components/timelineZones.ts +2 -3
  75. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  76. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  77. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  78. package/src/player/components/useAutomationLanes.ts +58 -74
  79. package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
  80. package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
  81. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  82. package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
  83. package/src/player/lib/automationStoreSync.ts +30 -3
  84. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  85. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  86. package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
  87. package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
  88. package/src/player/lib/timelineDOM.test.ts +28 -8
  89. package/src/player/lib/timelineElementHelpers.ts +3 -4
  90. package/src/player/lib/timelineGroupInfo.ts +23 -23
  91. package/src/utils/authoredSource.ts +92 -0
  92. package/src/utils/gsapSoftReload.test.ts +29 -0
  93. package/src/utils/gsapSoftReload.ts +3 -7
  94. package/src/utils/studioFileHistory.ts +36 -30
  95. package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
  96. package/src/player/components/automationGestureKeys.test.ts +0 -49
  97. package/src/player/components/automationGestureKeys.ts +0 -49
  98. /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
  99. /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
@@ -5,6 +5,7 @@ import { decodeVideoThumbnail, videoThumbnailTimestamps } from "./thumbnailVideo
5
5
 
6
6
  const dispose = vi.fn();
7
7
  const canvasesAtTimestamps = vi.fn();
8
+ const getKeyPacket = vi.fn(async (_time: number) => null as { timestamp: number } | null);
8
9
  const input = {
9
10
  getPrimaryVideoTrack: vi.fn(),
10
11
  dispose,
@@ -22,10 +23,25 @@ vi.mock("mediabunny", () => ({
22
23
  CanvasSink: class {
23
24
  canvasesAtTimestamps = canvasesAtTimestamps;
24
25
  },
26
+ EncodedPacketSink: class {
27
+ getKeyPacket = getKeyPacket;
28
+ },
25
29
  }));
26
30
 
31
+ function recordDecodes(decoded: number[][]): void {
32
+ canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
33
+ const run: number[] = [];
34
+ decoded.push(run);
35
+ for await (const time of timestamps) {
36
+ run.push(time);
37
+ yield { canvas: document.createElement("canvas") };
38
+ }
39
+ });
40
+ }
41
+
27
42
  beforeEach(() => {
28
43
  vi.clearAllMocks();
44
+ getKeyPacket.mockImplementation(async () => null);
29
45
  vi.spyOn(URL, "createObjectURL").mockReturnValueOnce("blob:one").mockReturnValueOnce("blob:two");
30
46
  vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
31
47
  HTMLCanvasElement.prototype.toBlob = function toBlob(callback) {
@@ -52,17 +68,14 @@ describe("videoThumbnailTimestamps", () => {
52
68
 
53
69
  describe("decodeVideoThumbnail", () => {
54
70
  it("extracts sparse frames, returns object URLs, and disposes once", async () => {
55
- const canvas = document.createElement("canvas");
56
- canvasesAtTimestamps.mockImplementation(async function* (timestamps: number[]) {
57
- expect(timestamps).toEqual([2, 8]);
58
- yield { canvas, timestamp: 2, duration: 1 };
59
- yield { canvas, timestamp: 8, duration: 1 };
60
- });
71
+ const decoded: number[][] = [];
72
+ recordDecodes(decoded);
61
73
  const result = await decodeVideoThumbnail(
62
74
  { source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 6, frameCount: 2 },
63
75
  new AbortController().signal,
64
76
  );
65
77
 
78
+ expect(decoded).toEqual([[2, 8]]);
66
79
  expect(result.value).toEqual({
67
80
  kind: "filmstrip",
68
81
  urls: ["blob:one", "blob:two"],
@@ -74,6 +87,89 @@ describe("decodeVideoThumbnail", () => {
74
87
  expect(dispose).toHaveBeenCalledTimes(1);
75
88
  });
76
89
 
90
+ it("decodes each strip frame at its keyframe unless that keyframe is before the clip's range", async () => {
91
+ getKeyPacket.mockImplementation(async (time) => ({ timestamp: Math.floor(time / 4) * 4 }));
92
+ const decoded: number[][] = [];
93
+ recordDecodes(decoded);
94
+ await decodeVideoThumbnail(
95
+ { source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 8, frameCount: 3 },
96
+ new AbortController().signal,
97
+ );
98
+ expect(decoded).toEqual([[2, 4, 8]]);
99
+ });
100
+
101
+ it("keeps a slot's own time when its keyframe is more than half a slot earlier", async () => {
102
+ getKeyPacket.mockImplementation(async () => ({ timestamp: 0 }));
103
+ const decoded: number[][] = [];
104
+ recordDecodes(decoded);
105
+ await decodeVideoThumbnail(
106
+ { source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
107
+ new AbortController().signal,
108
+ );
109
+ await decodeVideoThumbnail(
110
+ { source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 1 },
111
+ new AbortController().signal,
112
+ );
113
+ expect(decoded).toEqual([[0, 5, 10], [5]]);
114
+ });
115
+
116
+ it("looks up each keyframe just before decoding it", async () => {
117
+ const events: string[] = [];
118
+ getKeyPacket.mockImplementation(async (time) => {
119
+ events.push(`key ${time}`);
120
+ return { timestamp: time };
121
+ });
122
+ canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
123
+ for await (const time of timestamps) {
124
+ events.push(`frame ${time}`);
125
+ yield { canvas: document.createElement("canvas") };
126
+ }
127
+ });
128
+ await decodeVideoThumbnail(
129
+ { source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
130
+ new AbortController().signal,
131
+ );
132
+ expect(events).toEqual(["key 0", "frame 0", "key 5", "frame 5", "key 10", "frame 10"]);
133
+ });
134
+
135
+ it("ends the decode times without throwing when cancelled during a keyframe lookup", async () => {
136
+ const controller = new AbortController();
137
+ getKeyPacket.mockImplementation(async () => (controller.abort(), null));
138
+ const decoded: number[] = [];
139
+ let timesEnded = false;
140
+ canvasesAtTimestamps.mockImplementation(async function* (timestamps: AsyncIterable<number>) {
141
+ for await (const time of timestamps) {
142
+ decoded.push(time);
143
+ yield { canvas: document.createElement("canvas") };
144
+ }
145
+ timesEnded = true;
146
+ });
147
+ await expect(
148
+ decodeVideoThumbnail(
149
+ { source: "/clip.mp4", sourceStart: 0, sourceRangeDuration: 10, frameCount: 3 },
150
+ controller.signal,
151
+ ),
152
+ ).rejects.toThrow("Aborted");
153
+ expect(getKeyPacket).toHaveBeenCalledTimes(1);
154
+ expect(decoded).toEqual([]);
155
+ expect(timesEnded).toBe(true);
156
+ });
157
+
158
+ it("spreads a trimmed strip across its range when the file reports no duration", async () => {
159
+ input.getPrimaryVideoTrack.mockResolvedValue({
160
+ getDisplayWidth: vi.fn(async () => 1080),
161
+ getDisplayHeight: vi.fn(async () => 1920),
162
+ getDurationFromMetadata: vi.fn(async () => null),
163
+ });
164
+ const decoded: number[][] = [];
165
+ recordDecodes(decoded);
166
+ await decodeVideoThumbnail(
167
+ { source: "/clip.mp4", sourceStart: 5, sourceRangeDuration: 5, frameCount: 3 },
168
+ new AbortController().signal,
169
+ );
170
+ expect(decoded).toEqual([[5, 7.5, 10]]);
171
+ });
172
+
77
173
  it("releases input and degrades when the source has no video track", async () => {
78
174
  input.getPrimaryVideoTrack.mockResolvedValue(null);
79
175
  await expect(
@@ -44,7 +44,7 @@ interface DecodedResources {
44
44
 
45
45
  interface ThumbnailCanvasSink {
46
46
  canvasesAtTimestamps(
47
- timestamps: number[],
47
+ timestamps: AsyncIterable<number>,
48
48
  ): AsyncIterable<{ canvas: HTMLCanvasElement | OffscreenCanvas } | null>;
49
49
  }
50
50
 
@@ -77,7 +77,7 @@ function targetDimensions(
77
77
 
78
78
  async function decodeFrames(
79
79
  sink: ThumbnailCanvasSink,
80
- timestamps: number[],
80
+ timestamps: AsyncIterable<number>,
81
81
  signal: AbortSignal,
82
82
  resources: DecodedResources,
83
83
  ): Promise<void> {
@@ -89,6 +89,7 @@ async function decodeFrames(
89
89
  throwIfAborted(signal);
90
90
  resources.urls.push(URL.createObjectURL(blob));
91
91
  }
92
+ throwIfAborted(signal);
92
93
  }
93
94
 
94
95
  function loadedResult(
@@ -138,8 +139,12 @@ export async function decodeVideoThumbnail(
138
139
  }
139
140
  const metadataDuration = await track.getDurationFromMetadata({ skipLiveWait: true });
140
141
  throwIfAborted(signal);
141
- const sourceDuration = Math.max(0, metadataDuration ?? request.sourceRangeDuration ?? 0);
142
- const sourceStart = Math.min(Math.max(0, request.sourceStart ?? 0), sourceDuration);
142
+ const requestedStart = Math.max(0, request.sourceStart ?? 0);
143
+ const sourceDuration = Math.max(
144
+ 0,
145
+ metadataDuration ?? requestedStart + (request.sourceRangeDuration ?? 0),
146
+ );
147
+ const sourceStart = Math.min(requestedStart, sourceDuration);
143
148
  const requestedDuration =
144
149
  request.sourceRangeDuration ?? Math.max(0, sourceDuration - sourceStart);
145
150
  const duration = Math.min(
@@ -151,6 +156,16 @@ export async function decodeVideoThumbnail(
151
156
  duration,
152
157
  Math.min(request.frameCount, budgets.richPreviewFrameCount),
153
158
  );
159
+ const keys = new mediabunny.EncodedPacketSink(track);
160
+ const maxKeyframeLead = duration / Math.max(2, timestamps.length - 1) / 2;
161
+ async function* decodeTimesAtNearbyKeyframes() {
162
+ for (const time of timestamps) {
163
+ const key = await keys.getKeyPacket(time, { metadataOnly: true });
164
+ if (signal.aborted) return;
165
+ const near = key && key.timestamp >= sourceStart && time - key.timestamp <= maxKeyframeLead;
166
+ yield near ? key.timestamp : time;
167
+ }
168
+ }
154
169
  const aspect = displayWidth / displayHeight;
155
170
  const target = targetDimensions(aspect, budgets);
156
171
  const sink = new mediabunny.CanvasSink(track, {
@@ -159,7 +174,7 @@ export async function decodeVideoThumbnail(
159
174
  fit: request.fit ?? "cover",
160
175
  poolSize: 1,
161
176
  });
162
- await decodeFrames(sink, timestamps, signal, resources);
177
+ await decodeFrames(sink, decodeTimesAtNearbyKeyframes(), signal, resources);
163
178
  return loadedResult(resources, aspect, target.width, target.height);
164
179
  } catch (error) {
165
180
  releaseDecodedResources(resources);
@@ -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;
@@ -0,0 +1,92 @@
1
+ import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
2
+
3
+ // Stamped as the preview stamps the files it serves, so a live element's hf-id finds its source.
4
+ export function parseSavedSource(html: string): Document {
5
+ return new DOMParser().parseFromString(ensureHfIds(html), "text/html");
6
+ }
7
+
8
+ const rootsByDoc = new WeakMap<Document, ParentNode[]>();
9
+
10
+ // A plain template is a runtime clone source, so ids inside it repeat across clones.
11
+ function searchRoots(root: ParentNode): ParentNode[] {
12
+ const templates = Array.from(root.querySelectorAll("template")).filter(isCompositionTemplate);
13
+ return [root, ...templates.flatMap((template) => searchRoots(template.content))];
14
+ }
15
+
16
+ function findByAttribute(doc: Document, name: string, value: string): Element | null {
17
+ let roots = rootsByDoc.get(doc);
18
+ if (!roots) rootsByDoc.set(doc, (roots = searchRoots(doc)));
19
+ const selector = `[${name}="${value.replace(/["\\]/g, "\\$&")}"]`;
20
+ for (const root of roots) {
21
+ const match = root.querySelector(selector);
22
+ if (match) return match;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function findAuthoredElement(doc: Document, live: Element): Element | null {
28
+ const hfId = live.getAttribute("data-hf-id");
29
+ if (hfId) return findByAttribute(doc, "data-hf-id", hfId);
30
+ return live.id ? findByAttribute(doc, "id", live.id) : null;
31
+ }
32
+
33
+ export function findAuthoredElementById(doc: Document, live: Element): Element | null {
34
+ return live.id ? findByAttribute(doc, "id", live.id) : null;
35
+ }
36
+
37
+ const PATH_ATTRS = ["src", "href"];
38
+ const CSS_URL = /\burl\(\s*(["']?)([^)"'\s](?:[^)"']*[^)"'\s])?)\1\s*\)/g;
39
+
40
+ function isRelative(path: string): boolean {
41
+ return !!path && !/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(path);
42
+ }
43
+
44
+ function toProjectPath(sourceFile: string, path: string): string {
45
+ const parts = sourceFile.split("/").slice(0, -1);
46
+ for (const part of path.split("/")) {
47
+ if (part === "..") parts.pop();
48
+ else if (part !== "." && part !== "") parts.push(part);
49
+ }
50
+ return parts.join("/");
51
+ }
52
+
53
+ function livePathsOf(live: Element): Set<string> {
54
+ const paths = new Set<string>();
55
+ for (const el of [live, ...Array.from(live.querySelectorAll("*"))]) {
56
+ for (const attr of PATH_ATTRS) paths.add(el.getAttribute(attr) ?? "");
57
+ for (const match of (el.getAttribute("style") ?? "").matchAll(CSS_URL)) paths.add(match[2]);
58
+ }
59
+ return paths;
60
+ }
61
+
62
+ // The preview inlines a sub-composition with its relative asset paths rebased to the project
63
+ // root (rewriteAssetPath in @hyperframes/parsers, which is not browser-safe); copy does the same.
64
+ export function authoredMarkup(authored: Element, live: Element, sourceFile: string): string {
65
+ if (!sourceFile.includes("/")) return authored.outerHTML;
66
+ const livePaths = livePathsOf(live);
67
+ const rebase = (path: string): string => {
68
+ if (!isRelative(path)) return path;
69
+ const cut = path.search(/[?#]/);
70
+ const suffix = cut < 0 ? "" : path.slice(cut);
71
+ const rebased = toProjectPath(sourceFile, cut < 0 ? path : path.slice(0, cut)) + suffix;
72
+ return path.startsWith("../") || livePaths.has(rebased) ? rebased : path;
73
+ };
74
+ const copy = authored.cloneNode(true) as Element;
75
+ for (const el of [copy, ...Array.from(copy.querySelectorAll("*"))]) {
76
+ for (const attr of PATH_ATTRS) {
77
+ const value = el.getAttribute(attr);
78
+ if (value) el.setAttribute(attr, rebase(value.trim()));
79
+ }
80
+ const style = el.getAttribute("style");
81
+ if (style) {
82
+ el.setAttribute(
83
+ "style",
84
+ style.replace(
85
+ CSS_URL,
86
+ (_, quote: string, url: string) => `url(${quote}${rebase(url)}${quote})`,
87
+ ),
88
+ );
89
+ }
90
+ }
91
+ return copy.outerHTML;
92
+ }
@@ -488,6 +488,35 @@ describe("applySoftReload authored-opacity restore", () => {
488
488
  expect(el.style.getPropertyPriority("opacity")).toBe("");
489
489
  });
490
490
 
491
+ it("keeps the stamp for an element whose hf-id the written file lacks, even if it shares an id", () => {
492
+ const el = document.createElement("img");
493
+ el.id = "title-card";
494
+ el.setAttribute("data-hf-id", "hf-from-another-file");
495
+ el.setAttribute("data-hf-authored-opacity", "0.5");
496
+ el.style.opacity = "0";
497
+
498
+ const opacity = restoreOpacity(
499
+ el,
500
+ '<html><body><img id="title-card" style="opacity: 0.9"></body></html>',
501
+ );
502
+
503
+ expect(opacity).toBe("0.5");
504
+ });
505
+
506
+ it("does not take a runtime clone's opacity from its plain template", () => {
507
+ const el = document.createElement("li");
508
+ el.id = "item";
509
+ el.setAttribute("data-hf-authored-opacity", "0.5");
510
+ el.style.opacity = "0";
511
+
512
+ const opacity = restoreOpacity(
513
+ el,
514
+ '<html><body><template><li id="item" style="opacity: 0.3"></li></template></body></html>',
515
+ );
516
+
517
+ expect(opacity).toBe("0.5");
518
+ });
519
+
491
520
  it("falls back to the parse-time stamp when no after-write HTML is given", () => {
492
521
  const el = document.createElement("img");
493
522
  el.setAttribute("data-hf-authored-opacity", "0.75");
@@ -1,4 +1,5 @@
1
1
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
2
+ import { findAuthoredElement, parseSavedSource } from "./authoredSource";
2
3
  import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
3
4
 
4
5
  type IframeWindow = Window & {
@@ -301,17 +302,12 @@ export function applySoftReload(
301
302
  const findAuthoredSource = (el: HTMLElement): Element | null => {
302
303
  if (authoredDoc === undefined) {
303
304
  try {
304
- authoredDoc = authoredHtml
305
- ? new DOMParser().parseFromString(authoredHtml, "text/html")
306
- : null;
305
+ authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
307
306
  } catch {
308
307
  authoredDoc = null;
309
308
  }
310
309
  }
311
- if (!authoredDoc) return null;
312
- const hfId = el.getAttribute("data-hf-id");
313
- if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
314
- return el.id ? authoredDoc.getElementById(el.id) : null;
310
+ return authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
315
311
  };
316
312
  const readAuthoredOpacity = (el: HTMLElement): string | null => {
317
313
  const source = findAuthoredSource(el);
@@ -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
  }