@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.
- package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
- package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.html +1 -1
- package/dist/index.js +7327 -6265
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/App.tsx +9 -4
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +11 -7
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +2 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /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
|
|
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
|
-
|
|
28
|
+
const edges = Array.from(
|
|
22
29
|
{ length: count },
|
|
23
|
-
(_, index) => safeStart + (safeDuration * index) /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
+
let next = 0;
|
|
200
|
+
for await (const wrapped of sink.canvasesAtTimestamps(times)) {
|
|
85
201
|
throwIfAborted(signal);
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
217
|
+
budgets: Readonly<TimelineViewportBudgets>,
|
|
218
|
+
frameCount = resources.urls.length,
|
|
100
219
|
): ThumbnailLoadedResult {
|
|
101
|
-
const
|
|
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
|
-
|
|
230
|
+
urls.length === 1
|
|
105
231
|
? { kind: "image", url: firstUrl, aspect }
|
|
106
|
-
: { kind: "filmstrip", urls
|
|
232
|
+
: { kind: "filmstrip", urls, aspect };
|
|
107
233
|
return {
|
|
108
234
|
value,
|
|
109
|
-
weight: width * height * 4 *
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
28
|
-
|
|
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(
|
|
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;
|