@hyperframes/studio 0.8.138 → 0.8.140

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 (135) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
  5. package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +20 -3
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +7327 -6265
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/TimelineToolbar.test.tsx +79 -1
  18. package/src/components/TimelineToolbar.tsx +14 -29
  19. package/src/components/editor/DomEditOverlay.tsx +11 -7
  20. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  21. package/src/components/editor/PropertyPanel.tsx +1 -0
  22. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEdits.ts +1 -1
  33. package/src/components/editor/manualEditsDom.ts +7 -7
  34. package/src/components/editor/manualEditsParsing.ts +14 -2
  35. package/src/components/editor/manualEditsTypes.ts +5 -0
  36. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  37. package/src/components/editor/manualOffsetDrag.ts +5 -0
  38. package/src/components/editor/previewGestureStarts.ts +284 -0
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  40. package/src/components/editor/propertyPanelFill.tsx +36 -13
  41. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  42. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  43. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  44. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  45. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  46. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  47. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  48. package/src/components/nle/NLEContext.test.ts +26 -0
  49. package/src/components/nle/NLEContext.tsx +8 -3
  50. package/src/components/nle/NLEPreview.test.ts +44 -1
  51. package/src/components/nle/NLEPreview.tsx +7 -2
  52. package/src/hooks/draggedGsapPosition.ts +8 -0
  53. package/src/hooks/editMoment.ts +13 -0
  54. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  55. package/src/hooks/gsapDragCommit.ts +2 -1
  56. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  57. package/src/hooks/gsapPositionDetection.ts +5 -3
  58. package/src/hooks/gsapResizeIntercept.ts +11 -4
  59. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  60. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  61. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  62. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  63. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  64. package/src/hooks/timelineEditingHelpers.ts +18 -2
  65. package/src/hooks/useDomSelection.test.ts +21 -2
  66. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  67. package/src/hooks/useEditHistoryActions.ts +2 -0
  68. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  69. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  70. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  71. package/src/hooks/useGsapAwareEditing.ts +148 -136
  72. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  73. package/src/hooks/useGsapScriptCommits.ts +5 -4
  74. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  75. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  76. package/src/hooks/usePreviewPersistence.ts +1 -4
  77. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  78. package/src/hooks/useSdkSession.ts +4 -1
  79. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  80. package/src/hooks/useThumbnailStripSize.ts +23 -1
  81. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  82. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  83. package/src/index.ts +2 -0
  84. package/src/player/components/AudioWaveform.test.tsx +153 -1
  85. package/src/player/components/AudioWaveform.tsx +93 -37
  86. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  87. package/src/player/components/Timeline.test.ts +3 -1
  88. package/src/player/components/TimelineCanvas.tsx +4 -0
  89. package/src/player/components/TimelineClip.tsx +20 -6
  90. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  91. package/src/player/components/TimelineClipFades.tsx +4 -4
  92. package/src/player/components/TimelineClipJoins.tsx +23 -18
  93. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  94. package/src/player/components/TimelineGroupRow.tsx +1 -0
  95. package/src/player/components/TimelineLaneClip.tsx +127 -0
  96. package/src/player/components/TimelineLanes.test.tsx +51 -6
  97. package/src/player/components/TimelineLanes.tsx +166 -178
  98. package/src/player/components/TimelineRuler.tsx +2 -0
  99. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  100. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  101. package/src/player/components/VideoThumbnail.tsx +37 -9
  102. package/src/player/components/thumbnailUtils.test.ts +17 -3
  103. package/src/player/components/thumbnailUtils.ts +17 -1
  104. package/src/player/components/timelineLayout.test.ts +3 -3
  105. package/src/player/components/timelineMotion.test.ts +27 -0
  106. package/src/player/components/timelineMotion.ts +62 -0
  107. package/src/player/components/timelineZoomInput.test.ts +438 -0
  108. package/src/player/components/timelineZoomInput.ts +456 -0
  109. package/src/player/components/useTimelineGeometry.ts +0 -3
  110. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  111. package/src/player/components/useTimelinePlayhead.ts +53 -59
  112. package/src/player/components/useTimelineProviderState.tsx +16 -21
  113. package/src/player/components/useTimelineZoom.ts +7 -12
  114. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  115. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  116. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  117. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  118. package/src/player/lib/thumbnailScheduler.ts +1 -1
  119. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  120. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  121. package/src/player/lib/timelineElementHelpers.ts +0 -11
  122. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  123. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  124. package/src/player/previewReloading.test.ts +48 -0
  125. package/src/player/previewReloading.ts +48 -0
  126. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  127. package/src/utils/rootDuration.ts +5 -4
  128. package/src/utils/studioPendingEdits.test.ts +27 -0
  129. package/src/utils/studioPendingEdits.ts +9 -0
  130. package/src/utils/timelineAssetDrop.test.ts +13 -4
  131. package/src/utils/timelineAssetDrop.ts +6 -2
  132. package/dist/chunk-TDWGXBUT.js.map +0 -1
  133. /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
  134. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  135. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -1,14 +1,21 @@
1
1
  import { TIMELINE_VIEWPORT_BUDGETS, type TimelineViewportBudgets } from "./timelineViewportBudgets";
2
- import type { ThumbnailLoadedResult, ThumbnailValue } from "./thumbnailScheduler";
2
+ import {
3
+ createThumbnailKey,
4
+ type ThumbnailLoadedResult,
5
+ type ThumbnailValue,
6
+ } from "./thumbnailScheduler";
3
7
 
4
8
  export interface VideoThumbnailDecodeRequest {
5
9
  source: string;
10
+ contentVersion?: string;
6
11
  sourceStart?: number;
7
12
  sourceRangeDuration?: number;
8
13
  frameCount: number;
9
14
  fit?: "contain" | "cover";
10
15
  }
11
16
 
17
+ const LAST_FRAME_INSET_S = 0.001;
18
+
12
19
  export function videoThumbnailTimestamps(
13
20
  start: number,
14
21
  duration: number,
@@ -18,10 +25,63 @@ export function videoThumbnailTimestamps(
18
25
  const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
19
26
  const count = Math.max(1, Number.isFinite(frameCount) ? Math.floor(frameCount) : 1);
20
27
  if (count === 1) return [safeStart + safeDuration / 2];
21
- return Array.from(
28
+ const edges = Array.from(
22
29
  { length: count },
23
- (_, index) => safeStart + (safeDuration * index) / (count - 1),
30
+ (_, index) => safeStart + (safeDuration * index) / count,
24
31
  );
32
+ return [...edges, safeStart + Math.max(0, safeDuration - LAST_FRAME_INSET_S)];
33
+ }
34
+
35
+ interface SharedFrame {
36
+ url: string;
37
+ decodedAt: number;
38
+ users: number;
39
+ }
40
+
41
+ interface HeldFrame {
42
+ key: string;
43
+ frame: SharedFrame;
44
+ }
45
+
46
+ interface FrameWindow {
47
+ sourceStart: number;
48
+ lead: number;
49
+ end: number;
50
+ }
51
+
52
+ const sharedFrames = new Map<string, SharedFrame[]>();
53
+ const sourceInfos = new Map<string, { version: string | undefined; info: SourceInfo }>();
54
+
55
+ const accepts = (window: FrameWindow, time: number, decodedAt: number) =>
56
+ decodedAt >= window.sourceStart && time - decodedAt <= (time >= window.end ? 0 : window.lead);
57
+
58
+ function takeSharedFrame(key: string, time: number, window: FrameWindow): SharedFrame | undefined {
59
+ const frame = sharedFrames.get(key)?.find((shared) => accepts(window, time, shared.decodedAt));
60
+ if (frame) frame.users += 1;
61
+ return frame;
62
+ }
63
+
64
+ function shareFrame(key: string, decodedAt: number, url: string): SharedFrame {
65
+ const frames = sharedFrames.get(key) ?? [];
66
+ const existing = frames.find((shared) => shared.decodedAt === decodedAt);
67
+ if (existing) {
68
+ existing.users += 1;
69
+ URL.revokeObjectURL(url);
70
+ return existing;
71
+ }
72
+ const frame = { url, decodedAt, users: 1 };
73
+ sharedFrames.set(key, [...frames, frame]);
74
+ return frame;
75
+ }
76
+
77
+ function releaseSharedFrames(held: HeldFrame[]): void {
78
+ for (const { key, frame } of held.splice(0)) {
79
+ if (--frame.users > 0) continue;
80
+ const rest = sharedFrames.get(key)?.filter((shared) => shared !== frame) ?? [];
81
+ if (rest.length > 0) sharedFrames.set(key, rest);
82
+ else sharedFrames.delete(key);
83
+ URL.revokeObjectURL(frame.url);
84
+ }
25
85
  }
26
86
 
27
87
  async function canvasToBlob(canvas: HTMLCanvasElement | OffscreenCanvas): Promise<Blob> {
@@ -38,7 +98,8 @@ async function canvasToBlob(canvas: HTMLCanvasElement | OffscreenCanvas): Promis
38
98
  }
39
99
 
40
100
  interface DecodedResources {
41
- urls: string[];
101
+ urls: (string | undefined)[];
102
+ held: HeldFrame[];
42
103
  canvases: Set<HTMLCanvasElement | OffscreenCanvas>;
43
104
  }
44
105
 
@@ -48,12 +109,18 @@ interface ThumbnailCanvasSink {
48
109
  ): AsyncIterable<{ canvas: HTMLCanvasElement | OffscreenCanvas } | null>;
49
110
  }
50
111
 
112
+ interface SourceInfo {
113
+ aspect: number;
114
+ metadataDuration: number | null;
115
+ }
116
+
51
117
  function throwIfAborted(signal: AbortSignal): void {
52
118
  if (signal.aborted) throw new DOMException("Aborted", "AbortError");
53
119
  }
54
120
 
55
121
  function releaseDecodedResources(resources: DecodedResources): void {
56
- for (const url of resources.urls.splice(0)) URL.revokeObjectURL(url);
122
+ resources.urls.length = 0;
123
+ releaseSharedFrames(resources.held);
57
124
  for (const canvas of resources.canvases) {
58
125
  canvas.width = 0;
59
126
  canvas.height = 0;
@@ -75,19 +142,71 @@ function targetDimensions(
75
142
  };
76
143
  }
77
144
 
145
+ function stripTimes(
146
+ request: VideoThumbnailDecodeRequest,
147
+ info: SourceInfo,
148
+ budgets: Readonly<TimelineViewportBudgets>,
149
+ ) {
150
+ const requestedStart = Math.max(0, request.sourceStart ?? 0);
151
+ const sourceDuration = Math.max(
152
+ 0,
153
+ info.metadataDuration ?? requestedStart + (request.sourceRangeDuration ?? 0),
154
+ );
155
+ const sourceStart = Math.min(requestedStart, sourceDuration);
156
+ const requestedDuration =
157
+ request.sourceRangeDuration ?? Math.max(0, sourceDuration - sourceStart);
158
+ const duration = Math.min(
159
+ Math.max(0, requestedDuration),
160
+ Math.max(0, sourceDuration - sourceStart),
161
+ );
162
+ const timestamps = videoThumbnailTimestamps(
163
+ sourceStart,
164
+ duration,
165
+ Math.min(request.frameCount, budgets.richPreviewFrameCount),
166
+ );
167
+ const slices = Math.max(1, timestamps.length - 1);
168
+ const window: FrameWindow = {
169
+ sourceStart,
170
+ lead: duration / Math.max(2, slices) / 2,
171
+ end: timestamps.length > 1 ? timestamps.at(-1)! : Infinity,
172
+ };
173
+ return { window, timestamps };
174
+ }
175
+
176
+ function takeDecodedFrames(
177
+ { timestamps, window }: ReturnType<typeof stripTimes>,
178
+ keyOf: (time: number) => string,
179
+ resources: DecodedResources,
180
+ ): number[] {
181
+ const missing: number[] = [];
182
+ timestamps.forEach((time, slot) => {
183
+ const key = keyOf(time);
184
+ const frame = takeSharedFrame(key, time, window);
185
+ if (!frame) return void missing.push(slot);
186
+ resources.urls[slot] = frame.url;
187
+ resources.held.push({ key, frame });
188
+ });
189
+ return missing;
190
+ }
191
+
78
192
  async function decodeFrames(
79
193
  sink: ThumbnailCanvasSink,
80
- timestamps: AsyncIterable<number>,
194
+ times: AsyncIterable<number>,
195
+ slots: { slot: number; key: string; decodedAt?: number }[],
81
196
  signal: AbortSignal,
82
197
  resources: DecodedResources,
83
198
  ): Promise<void> {
84
- for await (const wrapped of sink.canvasesAtTimestamps(timestamps)) {
199
+ let next = 0;
200
+ for await (const wrapped of sink.canvasesAtTimestamps(times)) {
85
201
  throwIfAborted(signal);
86
- if (!wrapped) continue;
202
+ const target = slots[next++];
203
+ if (!wrapped || target?.decodedAt === undefined) continue;
87
204
  resources.canvases.add(wrapped.canvas);
88
205
  const blob = await canvasToBlob(wrapped.canvas);
89
206
  throwIfAborted(signal);
90
- resources.urls.push(URL.createObjectURL(blob));
207
+ const frame = shareFrame(target.key, target.decodedAt, URL.createObjectURL(blob));
208
+ resources.urls[target.slot] = frame.url;
209
+ resources.held.push({ key: target.key, frame });
91
210
  }
92
211
  throwIfAborted(signal);
93
212
  }
@@ -95,18 +214,25 @@ async function decodeFrames(
95
214
  function loadedResult(
96
215
  resources: DecodedResources,
97
216
  aspect: number,
98
- width: number,
99
- height: number,
217
+ budgets: Readonly<TimelineViewportBudgets>,
218
+ frameCount = resources.urls.length,
100
219
  ): ThumbnailLoadedResult {
101
- const firstUrl = resources.urls[0];
220
+ const decoded = resources.urls.filter((url): url is string => url !== undefined);
221
+ const firstUrl = decoded[0];
102
222
  if (!firstUrl) throw new Error("Video source returned no thumbnail frames");
223
+ let nearest = firstUrl;
224
+ const urls = Array.from(
225
+ { length: frameCount },
226
+ (_, slot) => (nearest = resources.urls[slot] ?? nearest),
227
+ );
228
+ const { width, height } = targetDimensions(aspect, budgets);
103
229
  const value: ThumbnailValue =
104
- resources.urls.length === 1
230
+ urls.length === 1
105
231
  ? { kind: "image", url: firstUrl, aspect }
106
- : { kind: "filmstrip", urls: [...resources.urls], aspect };
232
+ : { kind: "filmstrip", urls, aspect };
107
233
  return {
108
234
  value,
109
- weight: width * height * 4 * resources.urls.length,
235
+ weight: width * height * 4 * decoded.length,
110
236
  dispose: () => releaseDecodedResources(resources),
111
237
  };
112
238
  }
@@ -116,6 +242,34 @@ export async function decodeVideoThumbnail(
116
242
  request: VideoThumbnailDecodeRequest,
117
243
  signal: AbortSignal,
118
244
  budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
245
+ ): Promise<ThumbnailLoadedResult> {
246
+ const fit = request.fit ?? "cover";
247
+ const sourceKey = createThumbnailKey({ source: request.source, version: request.contentVersion });
248
+ const keyOf = (time: number) => `${sourceKey}\u0000${fit}\u0000${time}`;
249
+ const resources: DecodedResources = { urls: [], held: [], canvases: new Set() };
250
+ const entry = sourceInfos.get(request.source);
251
+ const known = entry?.version === request.contentVersion ? entry?.info : undefined;
252
+ try {
253
+ if (known) {
254
+ if (takeDecodedFrames(stripTimes(request, known, budgets), keyOf, resources).length === 0) {
255
+ throwIfAborted(signal);
256
+ return loadedResult(resources, known.aspect, budgets);
257
+ }
258
+ releaseDecodedResources(resources);
259
+ }
260
+ return await decodeMissingFrames(request, signal, budgets, keyOf, resources);
261
+ } catch (error) {
262
+ releaseDecodedResources(resources);
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ async function decodeMissingFrames(
268
+ request: VideoThumbnailDecodeRequest,
269
+ signal: AbortSignal,
270
+ budgets: Readonly<TimelineViewportBudgets>,
271
+ keyOf: (time: number) => string,
272
+ resources: DecodedResources,
119
273
  ): Promise<ThumbnailLoadedResult> {
120
274
  const mediabunny = await import("mediabunny");
121
275
  throwIfAborted(signal);
@@ -124,7 +278,6 @@ export async function decodeVideoThumbnail(
124
278
  source: new mediabunny.UrlSource(request.source),
125
279
  formats: mediabunny.ALL_FORMATS,
126
280
  });
127
- const resources: DecodedResources = { urls: [], canvases: new Set() };
128
281
  try {
129
282
  const track = await input.getPrimaryVideoTrack();
130
283
  throwIfAborted(signal);
@@ -139,46 +292,36 @@ export async function decodeVideoThumbnail(
139
292
  }
140
293
  const metadataDuration = await track.getDurationFromMetadata({ skipLiveWait: true });
141
294
  throwIfAborted(signal);
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);
148
- const requestedDuration =
149
- request.sourceRangeDuration ?? Math.max(0, sourceDuration - sourceStart);
150
- const duration = Math.min(
151
- Math.max(0, requestedDuration),
152
- Math.max(0, sourceDuration - sourceStart),
153
- );
154
- const timestamps = videoThumbnailTimestamps(
155
- sourceStart,
156
- duration,
157
- Math.min(request.frameCount, budgets.richPreviewFrameCount),
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;
295
+ const info: SourceInfo = { aspect: displayWidth / displayHeight, metadataDuration };
296
+ sourceInfos.set(request.source, { version: request.contentVersion, info });
297
+ const strip = stripTimes(request, info, budgets);
298
+ const { timestamps, window } = strip;
299
+ const slots: { slot: number; key: string; decodedAt?: number }[] = takeDecodedFrames(
300
+ strip,
301
+ keyOf,
302
+ resources,
303
+ ).map((slot) => ({ slot, key: keyOf(timestamps[slot]!) }));
304
+ if (slots.length > 0) {
305
+ const keys = new mediabunny.EncodedPacketSink(track);
306
+ async function* decodeTimesAtNearbyKeyframes() {
307
+ for (const target of slots) {
308
+ const time = timestamps[target.slot]!;
309
+ const key = await keys.getKeyPacket(time, { metadataOnly: true });
310
+ if (signal.aborted) return;
311
+ target.decodedAt = key && accepts(window, time, key.timestamp) ? key.timestamp : time;
312
+ yield target.decodedAt;
313
+ }
167
314
  }
315
+ const target = targetDimensions(info.aspect, budgets);
316
+ const sink = new mediabunny.CanvasSink(track, {
317
+ width: target.width,
318
+ height: target.height,
319
+ fit: request.fit ?? "cover",
320
+ poolSize: 1,
321
+ });
322
+ await decodeFrames(sink, decodeTimesAtNearbyKeyframes(), slots, signal, resources);
168
323
  }
169
- const aspect = displayWidth / displayHeight;
170
- const target = targetDimensions(aspect, budgets);
171
- const sink = new mediabunny.CanvasSink(track, {
172
- width: target.width,
173
- height: target.height,
174
- fit: request.fit ?? "cover",
175
- poolSize: 1,
176
- });
177
- await decodeFrames(sink, decodeTimesAtNearbyKeyframes(), signal, resources);
178
- return loadedResult(resources, aspect, target.width, target.height);
179
- } catch (error) {
180
- releaseDecodedResources(resources);
181
- throw error;
324
+ return loadedResult(resources, info.aspect, budgets, timestamps.length);
182
325
  } finally {
183
326
  input.dispose();
184
327
  }
@@ -130,17 +130,6 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
130
130
  return furthestClipEndFromDocument(doc);
131
131
  }
132
132
 
133
- /**
134
- * Furthest clip end parsed straight from a composition SOURCE STRING (the HTML
135
- * being saved). Uses raw `data-duration`, so it is the correct input for syncing
136
- * the root duration after an edit — reading the store instead would use the
137
- * runtime-truncated durations and shrink the composition (the feedback loop).
138
- */
139
- export function furthestClipEndFromSource(source: string): number {
140
- if (!source) return 0;
141
- return furthestClipEndFromDocument(new DOMParser().parseFromString(source, "text/html"));
142
- }
143
-
144
133
  // ---------------------------------------------------------------------------
145
134
  // DOM element type guards
146
135
  // ---------------------------------------------------------------------------
@@ -9,7 +9,7 @@ describe("timeline viewport budgets", () => {
9
9
  expect(TIMELINE_VIEWPORT_BUDGETS).toMatchObject({
10
10
  directScrollSafetyPx: 8_000_000,
11
11
  rowOverscanPerSide: 2,
12
- timeOverscanViewportRatio: 0.25,
12
+ timeOverscanViewportRatio: 0.5,
13
13
  maxMountedRows: 64,
14
14
  maxMountedClipRoots: 512,
15
15
  maxMountedClipRootsPerRow: 128,
@@ -61,7 +61,7 @@ export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); /
61
61
  export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Object.freeze({
62
62
  directScrollSafetyPx: 8_000_000,
63
63
  rowOverscanPerSide: 2,
64
- timeOverscanViewportRatio: 0.25,
64
+ timeOverscanViewportRatio: 0.5,
65
65
  maxMountedRows: 64,
66
66
  maxMountedClipRoots: 512,
67
67
  maxMountedClipRootsPerRow: 128,
@@ -0,0 +1,48 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ giveUpOnPreviewChange,
4
+ isPreviewChanging,
5
+ previewReloadBegun,
6
+ requestPreviewReload,
7
+ setPreviewReloading,
8
+ whileScriptWrites,
9
+ } from "./previewReloading";
10
+
11
+ afterEach(() => {
12
+ previewReloadBegun();
13
+ setPreviewReloading(false);
14
+ });
15
+
16
+ describe("the preview is changing", () => {
17
+ it("from a script write's send until it lands", async () => {
18
+ let land: () => void = () => {};
19
+ const write = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
20
+ expect(isPreviewChanging()).toBe(true);
21
+ land();
22
+ await write;
23
+ expect(isPreviewChanging()).toBe(false);
24
+ });
25
+
26
+ it("until a write that fails gives up, so a press never hangs on it", async () => {
27
+ await expect(whileScriptWrites(() => Promise.reject(new Error("rejected")))).rejects.toThrow();
28
+ expect(isPreviewChanging()).toBe(false);
29
+ });
30
+
31
+ it("from a reload's request until it has begun", () => {
32
+ requestPreviewReload();
33
+ expect(isPreviewChanging()).toBe(true);
34
+ previewReloadBegun();
35
+ expect(isPreviewChanging()).toBe(false);
36
+ });
37
+
38
+ it("stops counting a change a press gave up on, until the preview goes idle", async () => {
39
+ let land: () => void = () => {};
40
+ const stuck = whileScriptWrites(() => new Promise<void>((resolve) => (land = resolve)));
41
+ giveUpOnPreviewChange();
42
+ expect(isPreviewChanging()).toBe(false);
43
+ land();
44
+ await stuck;
45
+ requestPreviewReload();
46
+ expect(isPreviewChanging(), "the next change counts again").toBe(true);
47
+ });
48
+ });
@@ -0,0 +1,48 @@
1
+ import { thumbnailScheduler } from "./lib/thumbnailScheduler";
2
+
3
+ let reloading = false;
4
+ let reloadRequested = false;
5
+ let scriptWrites = 0;
6
+ // A press gave up on the change in progress (a save that never returns); later ones do not wait on it again.
7
+ let givenUp = false;
8
+
9
+ const changing = () => reloading || reloadRequested || scriptWrites > 0;
10
+ const noteIdle = () => {
11
+ if (!changing()) givenUp = false;
12
+ };
13
+
14
+ export function giveUpOnPreviewChange(): void {
15
+ givenUp = changing();
16
+ }
17
+
18
+ export function setPreviewReloading(next: boolean): void {
19
+ reloading = next;
20
+ thumbnailScheduler.setPreviewReloading(next);
21
+ noteIdle();
22
+ }
23
+
24
+ /** Studio asked for a reload that has not begun yet; `previewReloadBegun` closes that gap. */
25
+ export function requestPreviewReload(): void {
26
+ reloadRequested = true;
27
+ }
28
+
29
+ export function previewReloadBegun(): void {
30
+ reloadRequested = false;
31
+ noteIdle();
32
+ }
33
+
34
+ /** A script write or scene swap counts from its send until its preview update is applied, or it fails. */
35
+ export async function whileScriptWrites<T>(write: () => Promise<T>): Promise<T> {
36
+ scriptWrites += 1;
37
+ try {
38
+ return await write();
39
+ } finally {
40
+ scriptWrites -= 1;
41
+ noteIdle();
42
+ }
43
+ }
44
+
45
+ /** The one owner of "the preview is about to change": a canvas press waits for it. */
46
+ export function isPreviewChanging(): boolean {
47
+ return changing() && !givenUp;
48
+ }
@@ -1,16 +1,14 @@
1
1
  // Real Chrome: the box GSAP and a stylesheet translate produce together is what no DOM emulation computes.
2
2
  import { mkdtempSync, readFileSync } from "node:fs";
3
- import { createRequire } from "node:module";
4
3
  import { tmpdir } from "node:os";
5
4
  import { join } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
- import puppeteer, { type Browser } from "puppeteer-core";
6
+ import type { Browser } from "puppeteer-core";
8
7
  import { build, type Plugin } from "esbuild";
9
8
  import { afterAll, beforeAll, expect, it, vi } from "vitest";
10
- import { findSystemChrome } from "../../vite.browser";
9
+ import { launchTestChrome, showWithRuntime } from "../../tests/chromeTestUtils";
11
10
  import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
12
11
 
13
- const require = createRequire(import.meta.url);
14
12
  const CROP = "inset(0px 40px 0px 0px)";
15
13
  // Real Chrome on a loaded Windows runner exceeds Vitest's 5s test and 10s hook defaults.
16
14
  vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
@@ -44,9 +42,7 @@ async function bundle(file: string, name: string): Promise<string> {
44
42
  }
45
43
 
46
44
  beforeAll(async () => {
47
- const executablePath = findSystemChrome();
48
- if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
49
- browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
45
+ browser = await launchTestChrome();
50
46
  undoBundle = await bundle("./gsapUndoRestore.ts", "hfUndo");
51
47
  softReloadBundle = await bundle("./gsapSoftReload.ts", "hfSoftReload");
52
48
  });
@@ -62,16 +58,7 @@ async function openCase(spec: Spec, moduleBundle: string) {
62
58
  const restored = readFileSync(join(dir, "index.html"), "utf8");
63
59
  const page = await browser.newPage();
64
60
  await page.setViewport({ width: 1920, height: 1080 });
65
- await page.setRequestInterception(true);
66
- // Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
67
- page.on("request", (request) =>
68
- request.url().endsWith("/gsap.min.js")
69
- ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
70
- : request.abort("blockedbyclient"),
71
- );
72
- await page.setContent(restored, { waitUntil: "load" });
73
- await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
74
- await page.waitForFunction(() => "__player" in window);
61
+ await showWithRuntime(page, restored);
75
62
  await page.evaluate(moduleBundle);
76
63
  await page.evaluate(
77
64
  (time) => (window as unknown as { __player: { seek(t: number): void } }).__player.seek(time),
@@ -33,10 +33,11 @@ const DATA_DURATION_ATTR_RE = /(\bdata-duration\s*=\s*)(["'])[^"']*\2/i;
33
33
  * Deterministic and render-safe: DOMParser is the only DOM global used.
34
34
  */
35
35
  export function readRootCompositionDuration(source: string): number | null {
36
- const root = new DOMParser()
37
- .parseFromString(source, "text/html")
38
- .querySelector("[data-composition-id]");
39
- const raw = root?.getAttribute("data-duration");
36
+ return rootCompositionDurationOf(new DOMParser().parseFromString(source, "text/html"));
37
+ }
38
+
39
+ export function rootCompositionDurationOf(doc: Document): number | null {
40
+ const raw = doc.querySelector("[data-composition-id]")?.getAttribute("data-duration");
40
41
  if (raw == null) return null;
41
42
  return Number.parseFloat(raw);
42
43
  }
@@ -5,15 +5,42 @@ import {
5
5
  adoptingStudioPendingEdit,
6
6
  beginStudioPendingEdit,
7
7
  flushStudioPendingEdits,
8
+ afterStudioPendingEdits,
8
9
  hasStudioPendingEdits,
9
10
  isStudioEditSaving,
10
11
  paintBackNewestStudioPendingEdit,
12
+ setStudioWaitingPressCancel,
11
13
  trackStudioPendingEdit,
12
14
  trackedStudioEdit,
13
15
  } from "./studioPendingEdits";
14
16
  import { StudioFileConflictError } from "./studioSaveDiagnostics";
15
17
  import { revertNewestStudioPendingEdit as hostRevert } from "../index";
16
18
 
19
+ describe("a canvas press still waiting to run", () => {
20
+ it("is not what a host's revert takes back, since the host steps history after it", () => {
21
+ const cancel = vi.fn(() => true);
22
+ setStudioWaitingPressCancel(cancel);
23
+ try {
24
+ expect(hostRevert()).toBeNull();
25
+ expect(cancel).not.toHaveBeenCalled();
26
+ } finally {
27
+ setStudioWaitingPressCancel(null);
28
+ }
29
+ });
30
+
31
+ it("is never a pending edit, since a reload waits for those and the press waits for the reload", () => {
32
+ setStudioWaitingPressCancel(() => true);
33
+ const reload = vi.fn();
34
+ try {
35
+ afterStudioPendingEdits(reload);
36
+ expect(reload).toHaveBeenCalledOnce();
37
+ expect(hasStudioPendingEdits()).toBe(false);
38
+ } finally {
39
+ setStudioWaitingPressCancel(null);
40
+ }
41
+ });
42
+ });
43
+
17
44
  describe("studio pending edit flush", () => {
18
45
  it("waits for mounted panels to persist pending local edits", async () => {
19
46
  const persist = vi.fn(async () => undefined);
@@ -34,6 +34,15 @@ export function setStudioPendingEditClaimClock(read: (() => number) | null): voi
34
34
  historyClaims = read ?? (() => 0);
35
35
  }
36
36
  const NOT_SAVED = () => Promise.resolve(false);
37
+ let cancelWaitingPress: (() => boolean) | null = null;
38
+
39
+ export function setStudioWaitingPressCancel(cancel: (() => boolean) | null): void {
40
+ cancelWaitingPress = cancel;
41
+ }
42
+
43
+ export function cancelNewestStudioWaitingPress(): boolean {
44
+ return cancelWaitingPress?.() ?? false;
45
+ }
37
46
  let adopting: StudioEditInFlight | null = null;
38
47
 
39
48
  export function adoptingStudioPendingEdit(): StudioEditInFlight | null {
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  buildTimelineFileDropPlacements,
5
5
  buildTimelineAssetInsertHtml,
@@ -24,9 +24,18 @@ describe("setCompositionDurationToContent", () => {
24
24
  expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"');
25
25
  });
26
26
 
27
- it("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => {
28
- expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12));
29
- });
27
+ it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
28
+ "keeps the declared length without parsing when content end is %s",
29
+ (contentEnd) => {
30
+ const parse = vi.spyOn(DOMParser.prototype, "parseFromString");
31
+ try {
32
+ expect(setCompositionDurationToContent(src(12), contentEnd)).toBe(src(12));
33
+ expect(parse).not.toHaveBeenCalled();
34
+ } finally {
35
+ parse.mockRestore();
36
+ }
37
+ },
38
+ );
30
39
 
31
40
  it("is a no-op when already equal", () => {
32
41
  expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9));
@@ -155,9 +155,13 @@ export function extendCompositionDurationIfNeeded(source: string, requiredEnd: n
155
155
  * reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
156
156
  * an empty timeline keeps its declared duration instead of collapsing to 0.
157
157
  */
158
- export function setCompositionDurationToContent(source: string, contentEnd: number): string {
158
+ export function setCompositionDurationToContent(
159
+ source: string,
160
+ contentEnd: number,
161
+ rootDuration?: number | null,
162
+ ): string {
159
163
  if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
160
- const rootDur = readRootCompositionDuration(source);
164
+ const rootDur = rootDuration === undefined ? readRootCompositionDuration(source) : rootDuration;
161
165
  if (rootDur == null) return source;
162
166
  const next = roundToCenti(contentEnd);
163
167
  if (rootDur === next) return source;