@hyperframes/studio 0.8.64 → 0.8.65
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-CwFrSySS.js +464 -0
- package/dist/assets/{index-DLjjm1nC.js → index-Cs1lQrQ6.js} +142 -142
- package/dist/assets/{index-BEyJVAiH.js → index-a5nEhdAU.js} +1 -1
- package/dist/assets/{index-RRyhgH2k.js → index-wQFEeTL3.js} +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.html +1 -1
- package/dist/index.js +823 -748
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/manualEdits.ts +5 -1
- package/src/components/editor/manualEditsParsing.ts +15 -3
- package/src/components/sidebar/CompositionsPanel.masterComposition.test.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +2 -2
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.tsx +3 -2
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +30 -3
- package/src/hooks/useExternalFileChangeCoordinator.ts +12 -5
- package/src/hooks/useRenderClipContent.test.ts +21 -2
- package/src/hooks/useRenderClipContent.ts +10 -5
- package/src/hooks/useStudioExternalFileChanges.ts +6 -3
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/components/Timeline.virtualization.test.tsx +49 -4
- package/src/player/components/TimelineLanes.test.tsx +28 -0
- package/src/player/components/TimelineLanes.tsx +5 -4
- package/src/player/components/TimelineTransitionOverlays.tsx +3 -8
- package/src/player/components/timelineClipChildren.tsx +11 -1
- package/src/player/components/timelineProviderStateBuilders.test.ts +0 -7
- package/src/player/components/timelineProviderStateBuilders.ts +0 -9
- package/src/player/components/timelineTransitionSeams.test.ts +21 -1
- package/src/player/components/timelineTransitionSeams.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +11 -5
- package/src/player/hooks/useShadowPreviewReload.ts +7 -0
- package/src/player/lib/thumbnailScheduler.test.ts +33 -0
- package/src/player/lib/thumbnailScheduler.ts +34 -0
- package/src/player/store/playerStore.test.ts +22 -8
- package/src/player/store/thumbnailSlice.ts +28 -6
- package/dist/assets/hyperframes-player-pWkneUHC.js +0 -464
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.65",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"dockview-react": "8.3.1",
|
|
52
52
|
"gsap": "^3.13.0",
|
|
53
53
|
"mediabunny": "^1.45.3",
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/studio-server": "0.8.
|
|
54
|
+
"@hyperframes/player": "0.8.65",
|
|
55
|
+
"@hyperframes/sdk": "0.8.65",
|
|
56
|
+
"@hyperframes/parsers": "0.8.65",
|
|
57
|
+
"@hyperframes/core": "0.8.65",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.65"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"vite": "^6.4.2",
|
|
72
72
|
"vitest": "^4.1.11",
|
|
73
73
|
"zustand": "^5.0.0",
|
|
74
|
-
"@hyperframes/producer": "0.8.
|
|
74
|
+
"@hyperframes/producer": "0.8.65"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "19",
|
|
@@ -9,7 +9,11 @@ export {
|
|
|
9
9
|
type StudioPathOffsetSnapshot,
|
|
10
10
|
} from "./manualEditsTypes";
|
|
11
11
|
|
|
12
|
-
export {
|
|
12
|
+
export {
|
|
13
|
+
readFileChangeAffectedCompositions,
|
|
14
|
+
readFileChangeField,
|
|
15
|
+
readStudioFileChangePath,
|
|
16
|
+
} from "./manualEditsParsing";
|
|
13
17
|
|
|
14
18
|
export {
|
|
15
19
|
beginStudioManualEditGesture,
|
|
@@ -15,15 +15,17 @@ function normalizeStudioFileChangePath(path: string): string {
|
|
|
15
15
|
.replace(/^\.?\//, "");
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function asPayloadRecord(payload: unknown): Record<string, unknown> | null {
|
|
19
|
+
return payload && typeof payload === "object" ? (payload as Record<string, unknown>) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
18
22
|
/**
|
|
19
23
|
* Read one string field out of an ALREADY-DECODED file-change payload. Every
|
|
20
24
|
* reader of that payload goes through here, so no reader can disagree with its
|
|
21
25
|
* siblings about the shape. Decoding a raw delivery is the transport's job.
|
|
22
26
|
*/
|
|
23
27
|
export function readFileChangeField(payload: unknown, key: string): string | null {
|
|
24
|
-
|
|
25
|
-
const record = payload as Record<string, unknown>;
|
|
26
|
-
const value = record[key];
|
|
28
|
+
const value = asPayloadRecord(payload)?.[key];
|
|
27
29
|
return typeof value === "string" ? value : null;
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -31,3 +33,13 @@ export function readStudioFileChangePath(payload: unknown): string | null {
|
|
|
31
33
|
const path = readFileChangeField(payload, "path") ?? readFileChangeField(payload, "filePath");
|
|
32
34
|
return path === null ? null : normalizeStudioFileChangePath(path);
|
|
33
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The compositions whose thumbnails a change can alter, or `null` for all of them. Anything
|
|
39
|
+
* but a list of paths (an older server, the Vite dev host) reads as "all".
|
|
40
|
+
*/
|
|
41
|
+
export function readFileChangeAffectedCompositions(payload: unknown): readonly string[] | null {
|
|
42
|
+
const value = asPayloadRecord(payload)?.affectedCompositions;
|
|
43
|
+
if (!Array.isArray(value) || !value.every((path) => typeof path === "string")) return null;
|
|
44
|
+
return value.map(normalizeStudioFileChangePath);
|
|
45
|
+
}
|
|
@@ -17,7 +17,7 @@ afterEach(() => {
|
|
|
17
17
|
if (root) act(() => root?.unmount());
|
|
18
18
|
root = null;
|
|
19
19
|
document.body.innerHTML = "";
|
|
20
|
-
usePlayerStore.setState({
|
|
20
|
+
usePlayerStore.setState({ thumbnailRevisions: {} });
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
describe("CompositionsPanel root badge source", () => {
|
|
@@ -18,7 +18,7 @@ afterEach(() => {
|
|
|
18
18
|
if (root) act(() => root?.unmount());
|
|
19
19
|
root = null;
|
|
20
20
|
document.body.innerHTML = "";
|
|
21
|
-
usePlayerStore.setState({
|
|
21
|
+
usePlayerStore.setState({ thumbnailRevisions: {} });
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
|
@@ -68,7 +68,7 @@ describe("composition card drag", () => {
|
|
|
68
68
|
act(() => thumbnail.dispatchEvent(new Event("error")));
|
|
69
69
|
expect(host.textContent).toContain("Preview unavailable");
|
|
70
70
|
|
|
71
|
-
act(() => usePlayerStore.getState().
|
|
71
|
+
act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
|
|
72
72
|
|
|
73
73
|
const retry = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
|
74
74
|
expect(retry).not.toBeNull();
|
|
@@ -18,7 +18,7 @@ afterEach(() => {
|
|
|
18
18
|
if (root) act(() => root?.unmount());
|
|
19
19
|
root = null;
|
|
20
20
|
document.body.innerHTML = "";
|
|
21
|
-
usePlayerStore.setState({
|
|
21
|
+
usePlayerStore.setState({ thumbnailRevisions: {} });
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
function mount(compositions: string[], masterCompositionPath: string | null) {
|
|
@@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
|
|
4
4
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
5
5
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
|
+
import { thumbnailRevisionOf } from "../../player/store/thumbnailSlice";
|
|
6
7
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
7
8
|
import { Tooltip } from "../ui/Tooltip";
|
|
8
9
|
|
|
@@ -388,7 +389,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
388
389
|
isRendering,
|
|
389
390
|
lintFindingsByFile,
|
|
390
391
|
}: CompositionsTabProps) {
|
|
391
|
-
const
|
|
392
|
+
const thumbnailRevisions = usePlayerStore((state) => state.thumbnailRevisions);
|
|
392
393
|
if (compositions.length === 0) {
|
|
393
394
|
return (
|
|
394
395
|
<div className="flex-1 flex items-center justify-center px-4">
|
|
@@ -411,7 +412,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
411
412
|
onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
|
|
412
413
|
isRendering={isRendering}
|
|
413
414
|
lintInfo={lintFindingsByFile?.get(comp)}
|
|
414
|
-
contentRevision={
|
|
415
|
+
contentRevision={thumbnailRevisionOf(thumbnailRevisions, comp)}
|
|
415
416
|
/>
|
|
416
417
|
))}
|
|
417
418
|
</div>
|
|
@@ -104,7 +104,7 @@ describe("external file change coordinator", () => {
|
|
|
104
104
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
105
105
|
expect(reloadSdkSession).not.toHaveBeenCalled();
|
|
106
106
|
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
107
|
-
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
|
|
107
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html", null);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
it("accepts a matching content echo without a write token and suppresses every reload", async () => {
|
|
@@ -125,7 +125,7 @@ describe("external file change coordinator", () => {
|
|
|
125
125
|
);
|
|
126
126
|
|
|
127
127
|
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
128
|
-
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
|
|
128
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html", null);
|
|
129
129
|
expect(drainPendingChanges).not.toHaveBeenCalled();
|
|
130
130
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
131
131
|
expect(reloadSdkSession).not.toHaveBeenCalled();
|
|
@@ -345,7 +345,34 @@ describe("external file change coordinator", () => {
|
|
|
345
345
|
|
|
346
346
|
expect(drainPendingChanges).not.toHaveBeenCalled();
|
|
347
347
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
348
|
-
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
|
|
348
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html", null);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("hands the server's affected compositions to the thumbnail refresh", async () => {
|
|
352
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
353
|
+
await mountCoordinator({ onAcceptedPersistedFileChange });
|
|
354
|
+
|
|
355
|
+
await act(async () =>
|
|
356
|
+
handler?.(
|
|
357
|
+
sseDelivery({
|
|
358
|
+
path: "compositions/scene-a.html",
|
|
359
|
+
version: "v3",
|
|
360
|
+
affectedCompositions: ["index.html", "compositions/scene-a.html"],
|
|
361
|
+
}),
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
await act(async () =>
|
|
365
|
+
handler?.(
|
|
366
|
+
sseDelivery({ path: "assets/logo.svg", version: "v4", affectedCompositions: "all" }),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenNthCalledWith(
|
|
371
|
+
1,
|
|
372
|
+
"compositions/scene-a.html",
|
|
373
|
+
["index.html", "compositions/scene-a.html"],
|
|
374
|
+
);
|
|
375
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenNthCalledWith(2, "assets/logo.svg", null);
|
|
349
376
|
});
|
|
350
377
|
|
|
351
378
|
it("reloads once when one watcher event reaches two subscribers", async () => {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
readFileChangeAffectedCompositions,
|
|
4
|
+
readFileChangeField,
|
|
5
|
+
readStudioFileChangePath,
|
|
6
|
+
} from "../components/editor/manualEdits";
|
|
3
7
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
4
8
|
import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
|
|
5
9
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
@@ -56,7 +60,10 @@ interface ExternalFileChangeCoordinatorOptions {
|
|
|
56
60
|
readProjectFile: (path: string) => Promise<string>;
|
|
57
61
|
onUseExternalFile?: (path: string, content: string) => void;
|
|
58
62
|
resetSaveQueues?: () => void;
|
|
59
|
-
onAcceptedPersistedFileChange: (
|
|
63
|
+
onAcceptedPersistedFileChange: (
|
|
64
|
+
path: string,
|
|
65
|
+
affectedCompositions: readonly string[] | null,
|
|
66
|
+
) => void;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
export interface ExternalFileChangeCoordinatorHandle {
|
|
@@ -253,7 +260,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
253
260
|
}
|
|
254
261
|
if (!mountedRef.current || generation !== generationRef.current) return;
|
|
255
262
|
setBlocked(null);
|
|
256
|
-
onAcceptedPersistedFileChange(path);
|
|
263
|
+
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
|
|
257
264
|
reloadAcceptedGeneration(path);
|
|
258
265
|
return;
|
|
259
266
|
}
|
|
@@ -377,7 +384,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
377
384
|
const ownWriteToken = consumeStudioWriteToken(token);
|
|
378
385
|
const ownContentEcho = content != null && isSelfWriteEcho(path, content);
|
|
379
386
|
if (ownWriteToken || ownContentEcho) {
|
|
380
|
-
onAcceptedPersistedFileChange(path);
|
|
387
|
+
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
|
|
381
388
|
logReload("suppressed", {
|
|
382
389
|
path,
|
|
383
390
|
why: ownWriteToken ? "own write token" : "own content echo",
|
|
@@ -430,7 +437,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
430
437
|
onUseExternalFile?.(path, external);
|
|
431
438
|
await deleteConflictSnapshot?.(projectId, path);
|
|
432
439
|
setBlocked(null);
|
|
433
|
-
onAcceptedPersistedFileChange(path);
|
|
440
|
+
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(current.payload));
|
|
434
441
|
reloadAcceptedGeneration(path);
|
|
435
442
|
},
|
|
436
443
|
[
|
|
@@ -271,11 +271,29 @@ describe("useRenderClipContent", () => {
|
|
|
271
271
|
}
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
+
it("finds the revision of a composition mounted with a ./ path", () => {
|
|
275
|
+
usePlayerStore.setState({
|
|
276
|
+
thumbnailMode: "adaptive",
|
|
277
|
+
thumbnailRevisions: { "compositions/nested.html": 2 },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const content = renderClipContent({
|
|
281
|
+
id: "nested",
|
|
282
|
+
tag: "div",
|
|
283
|
+
start: 0,
|
|
284
|
+
duration: 4,
|
|
285
|
+
track: 0,
|
|
286
|
+
compositionSrc: "./compositions/nested.html",
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(isValidElement(content) && content.props).toMatchObject({ contentRevision: 2 });
|
|
290
|
+
});
|
|
291
|
+
|
|
274
292
|
it("forwards persisted content revision to mounted composition thumbnails", () => {
|
|
275
293
|
usePlayerStore.setState({
|
|
276
294
|
thumbnailMode: "adaptive",
|
|
277
295
|
timelineSessionEpoch: 7,
|
|
278
|
-
|
|
296
|
+
thumbnailRevisions: { "*": 11, "compositions/nested.html": 2, "compositions/other.html": 5 },
|
|
279
297
|
});
|
|
280
298
|
|
|
281
299
|
const content = renderClipContent({
|
|
@@ -293,7 +311,8 @@ describe("useRenderClipContent", () => {
|
|
|
293
311
|
expect(content.props).toMatchObject({
|
|
294
312
|
projectId: "my-project",
|
|
295
313
|
sessionEpoch: 7,
|
|
296
|
-
|
|
314
|
+
// its own composition's revision plus the all-compositions one, not a sibling's
|
|
315
|
+
contentRevision: 13,
|
|
297
316
|
});
|
|
298
317
|
}
|
|
299
318
|
});
|
|
@@ -2,6 +2,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
|
|
|
2
2
|
import { useCallback, type ReactNode } from "react";
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
5
|
+
import { compositionPathOfPreviewUrl } from "../player/components/CompositionThumbnail";
|
|
5
6
|
import type { TimelineElement } from "../player";
|
|
6
7
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
7
8
|
import { audioPillFlags } from "../player/components/audioClipLink";
|
|
@@ -9,6 +10,7 @@ import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
|
9
10
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
10
11
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
11
12
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
13
|
+
import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
|
|
12
14
|
import { effectiveThumbnailMode } from "../player/lib/thumbnailPolicy";
|
|
13
15
|
|
|
14
16
|
export function normalizeCompositionSrc(
|
|
@@ -114,7 +116,7 @@ export function useRenderClipContent({
|
|
|
114
116
|
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
|
|
115
117
|
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
|
|
116
118
|
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
117
|
-
const
|
|
119
|
+
const thumbnailRevisions = usePlayerStore((s) => s.thumbnailRevisions);
|
|
118
120
|
const elements = usePlayerStore((s) => s.elements);
|
|
119
121
|
return useCallback(
|
|
120
122
|
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
|
|
@@ -159,7 +161,7 @@ export function useRenderClipContent({
|
|
|
159
161
|
duration: el.duration,
|
|
160
162
|
projectId: pid,
|
|
161
163
|
sessionEpoch,
|
|
162
|
-
contentRevision,
|
|
164
|
+
contentRevision: thumbnailRevisionOf(thumbnailRevisions, compSrc),
|
|
163
165
|
priority: context.priority,
|
|
164
166
|
rich: context.rich,
|
|
165
167
|
});
|
|
@@ -186,7 +188,10 @@ export function useRenderClipContent({
|
|
|
186
188
|
duration: el.duration,
|
|
187
189
|
projectId: pid,
|
|
188
190
|
sessionEpoch,
|
|
189
|
-
contentRevision
|
|
191
|
+
contentRevision: thumbnailRevisionOf(
|
|
192
|
+
thumbnailRevisions,
|
|
193
|
+
compositionPathOfPreviewUrl(activePreviewUrl),
|
|
194
|
+
),
|
|
190
195
|
priority: context.priority,
|
|
191
196
|
rich: context.rich,
|
|
192
197
|
});
|
|
@@ -239,7 +244,7 @@ export function useRenderClipContent({
|
|
|
239
244
|
duration: el.duration,
|
|
240
245
|
projectId: pid,
|
|
241
246
|
sessionEpoch,
|
|
242
|
-
contentRevision,
|
|
247
|
+
contentRevision: thumbnailRevisionOf(thumbnailRevisions, "index.html"),
|
|
243
248
|
priority: context.priority,
|
|
244
249
|
rich: context.rich,
|
|
245
250
|
});
|
|
@@ -254,7 +259,7 @@ export function useRenderClipContent({
|
|
|
254
259
|
effectiveTimelineDuration,
|
|
255
260
|
effectiveMode,
|
|
256
261
|
sessionEpoch,
|
|
257
|
-
|
|
262
|
+
thumbnailRevisions,
|
|
258
263
|
elements,
|
|
259
264
|
],
|
|
260
265
|
);
|
|
@@ -49,8 +49,11 @@ export function useStudioExternalFileChanges({
|
|
|
49
49
|
}: UseStudioExternalFileChangesOptions) {
|
|
50
50
|
const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
|
|
51
51
|
const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
|
|
52
|
-
const
|
|
53
|
-
|
|
52
|
+
const bumpThumbnailRevisions = usePlayerStore((state) => state.bumpThumbnailRevisions);
|
|
53
|
+
const onAcceptedPersistedFileChange = useCallback(
|
|
54
|
+
(_path: string, affectedCompositions: readonly string[] | null) =>
|
|
55
|
+
bumpThumbnailRevisions(affectedCompositions),
|
|
56
|
+
[bumpThumbnailRevisions],
|
|
54
57
|
);
|
|
55
58
|
const drainPendingChanges = useCallback(async () => {
|
|
56
59
|
const source = await flushPendingSourceSave();
|
|
@@ -81,6 +84,6 @@ export function useStudioExternalFileChanges({
|
|
|
81
84
|
readProjectFile: fileManager.readProjectFile,
|
|
82
85
|
onUseExternalFile: fileManager.updateEditingFileContent,
|
|
83
86
|
resetSaveQueues: resetDomEditSaveQueueBreaker,
|
|
84
|
-
onAcceptedPersistedFileChange
|
|
87
|
+
onAcceptedPersistedFileChange,
|
|
85
88
|
});
|
|
86
89
|
}
|
|
@@ -66,6 +66,12 @@ export function buildCompositionThumbnailUrl({
|
|
|
66
66
|
return thumbnailUrl.toString();
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** The composition a preview URL renders: `/preview/comp/<path>`, or the root for `/preview`. */
|
|
70
|
+
export function compositionPathOfPreviewUrl(previewUrl: string): string {
|
|
71
|
+
const match = /\/preview\/comp\/([^?#]+)/.exec(previewUrl);
|
|
72
|
+
return match?.[1] ? decodeURIComponent(match[1]) : "index.html";
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
async function loadCompositionImage(url: string, signal: AbortSignal) {
|
|
70
76
|
const response = await fetch(url, { signal });
|
|
71
77
|
if (!response.ok) throw new Error(`Composition thumbnail failed (${response.status})`);
|
|
@@ -140,7 +140,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
140
140
|
usePlayerStore.getState().reset();
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
it("
|
|
143
|
+
it("keeps a clip's picture on screen while the timeline scrolls", async () => {
|
|
144
144
|
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
|
145
145
|
import("./Timeline"),
|
|
146
146
|
import("../store/playerStore"),
|
|
@@ -167,17 +167,62 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
|
|
167
167
|
expect(scroller).not.toBeNull();
|
|
168
168
|
expect(clip).not.toBeNull();
|
|
169
169
|
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
|
|
170
|
-
|
|
170
|
+
const picture = host.querySelector("[data-rich-content]");
|
|
171
|
+
expect(picture).not.toBeNull();
|
|
171
172
|
|
|
172
173
|
if (scroller) await dispatchScroll(scroller);
|
|
173
174
|
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
|
174
|
-
expect(host.querySelector("[data-rich-content]")).
|
|
175
|
+
expect(host.querySelector("[data-rich-content]")).toBe(picture);
|
|
175
176
|
|
|
176
177
|
await act(async () => {
|
|
177
178
|
await new Promise((resolve) => setTimeout(resolve, 110));
|
|
178
179
|
});
|
|
179
180
|
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
|
180
|
-
expect(host.querySelector("[data-rich-content]")).
|
|
181
|
+
expect(host.querySelector("[data-rich-content]")).toBe(picture);
|
|
182
|
+
} finally {
|
|
183
|
+
act(() => root.unmount());
|
|
184
|
+
usePlayerStore.getState().reset();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("gives a clip that appears mid-scroll its picture once the scroll settles", async () => {
|
|
189
|
+
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
|
190
|
+
import("./Timeline"),
|
|
191
|
+
import("../store/playerStore"),
|
|
192
|
+
]);
|
|
193
|
+
const clip = (id: string, start: number) => ({
|
|
194
|
+
id,
|
|
195
|
+
label: id,
|
|
196
|
+
tag: "div",
|
|
197
|
+
start,
|
|
198
|
+
duration: 5,
|
|
199
|
+
track: 0,
|
|
200
|
+
});
|
|
201
|
+
usePlayerStore.setState({ duration: 60, timelineReady: true, elements: [clip("clip-0", 0)] });
|
|
202
|
+
|
|
203
|
+
const { host, root } = await mountTimeline(
|
|
204
|
+
React.createElement(Timeline, {
|
|
205
|
+
renderClipContent: (element: { id: string }) =>
|
|
206
|
+
React.createElement("span", { "data-rich-content": element.id }),
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
try {
|
|
210
|
+
await act(async () => {
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 110));
|
|
212
|
+
});
|
|
213
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
214
|
+
if (scroller) await dispatchScroll(scroller);
|
|
215
|
+
act(() => {
|
|
216
|
+
usePlayerStore.setState({ elements: [clip("clip-0", 0), clip("clip-1", 5)] });
|
|
217
|
+
});
|
|
218
|
+
expect(host.querySelector('[data-el-id="clip-1"]')).not.toBeNull();
|
|
219
|
+
expect(host.querySelector('[data-rich-content="clip-1"]')).toBeNull();
|
|
220
|
+
expect(host.querySelector('[data-rich-content="clip-0"]')).not.toBeNull();
|
|
221
|
+
|
|
222
|
+
await act(async () => {
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 110));
|
|
224
|
+
});
|
|
225
|
+
expect(host.querySelector('[data-rich-content="clip-1"]')).not.toBeNull();
|
|
181
226
|
} finally {
|
|
182
227
|
act(() => root.unmount());
|
|
183
228
|
usePlayerStore.getState().reset();
|
|
@@ -14,6 +14,16 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
|
14
14
|
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
15
15
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
16
16
|
import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
17
|
+
import * as transitionSeams from "./timelineTransitionSeams";
|
|
18
|
+
|
|
19
|
+
vi.mock("./timelineTransitionSeams", async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal<typeof import("./timelineTransitionSeams")>();
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
deriveTimelineTransitionSeams: vi.fn(actual.deriveTimelineTransitionSeams),
|
|
24
|
+
deriveTimelineTransitionSeamsByTrack: vi.fn(actual.deriveTimelineTransitionSeamsByTrack),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
17
27
|
|
|
18
28
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
29
|
|
|
@@ -497,3 +507,21 @@ describe("TimelineLanes clip joins", () => {
|
|
|
497
507
|
act(() => view.root.unmount());
|
|
498
508
|
});
|
|
499
509
|
});
|
|
510
|
+
|
|
511
|
+
describe("TimelineLanes transition seams", () => {
|
|
512
|
+
it("derives transition seams once for every row, not once per row", () => {
|
|
513
|
+
const derivations = [
|
|
514
|
+
transitionSeams.deriveTimelineTransitionSeams,
|
|
515
|
+
transitionSeams.deriveTimelineTransitionSeamsByTrack,
|
|
516
|
+
].map((derive) => vi.mocked(derive));
|
|
517
|
+
for (const derive of derivations) derive.mockClear();
|
|
518
|
+
|
|
519
|
+
const view = renderLanes({
|
|
520
|
+
elements: [element("clip-a", 0), element("clip-b", TRACK_A), element("clip-c", TRACK_B)],
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
expect(view.host.querySelectorAll("[data-timeline-row]").length).toBeGreaterThanOrEqual(3);
|
|
524
|
+
expect(derivations.reduce((calls, derive) => calls + derive.mock.calls.length, 0)).toBe(1);
|
|
525
|
+
act(() => view.root.unmount());
|
|
526
|
+
});
|
|
527
|
+
});
|
|
@@ -33,6 +33,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
|
33
33
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
34
34
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
35
35
|
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
|
|
36
|
+
import { deriveTimelineTransitionSeamsByTrack } from "./timelineTransitionSeams";
|
|
36
37
|
|
|
37
38
|
export function TimelineLanes({
|
|
38
39
|
pps,
|
|
@@ -105,8 +106,9 @@ export function TimelineLanes({
|
|
|
105
106
|
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
|
106
107
|
useTimelineGroupDisclosure();
|
|
107
108
|
const automationLanes = useAutomationLanes();
|
|
108
|
-
const
|
|
109
|
-
() =>
|
|
109
|
+
const transitionSeamsByTrack = useMemo(
|
|
110
|
+
() =>
|
|
111
|
+
deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
|
|
110
112
|
[getPreviewElement, tracks],
|
|
111
113
|
);
|
|
112
114
|
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
|
|
@@ -551,8 +553,7 @@ export function TimelineLanes({
|
|
|
551
553
|
})
|
|
552
554
|
}
|
|
553
555
|
<TimelineTransitionOverlays
|
|
554
|
-
|
|
555
|
-
track={trackNum}
|
|
556
|
+
seams={transitionSeamsByTrack.get(trackNum) ?? []}
|
|
556
557
|
rowElements={draggedClip?.started ? [] : automationElements}
|
|
557
558
|
rowBackground={rowBackground}
|
|
558
559
|
pixelsPerSecond={pps}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { CLIP_Y } from "./timelineLayout";
|
|
2
2
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
3
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
-
import {
|
|
4
|
+
import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
|
|
5
5
|
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
|
|
6
6
|
import { TimelineClipJoins } from "./TimelineClipJoins";
|
|
7
7
|
|
|
8
8
|
interface TimelineTransitionOverlaysProps {
|
|
9
|
-
|
|
10
|
-
track: number;
|
|
9
|
+
seams: readonly TimelineTransitionSeam[];
|
|
11
10
|
rowElements: readonly TimelineElement[];
|
|
12
11
|
rowBackground: string;
|
|
13
12
|
pixelsPerSecond: number;
|
|
@@ -17,8 +16,7 @@ interface TimelineTransitionOverlaysProps {
|
|
|
17
16
|
|
|
18
17
|
/** What a row draws where its clips meet: a hairline at an exact join, a badge over a transition. */
|
|
19
18
|
export function TimelineTransitionOverlays({
|
|
20
|
-
|
|
21
|
-
track,
|
|
19
|
+
seams,
|
|
22
20
|
rowElements,
|
|
23
21
|
rowBackground,
|
|
24
22
|
pixelsPerSecond,
|
|
@@ -26,9 +24,6 @@ export function TimelineTransitionOverlays({
|
|
|
26
24
|
clipBarHeight,
|
|
27
25
|
}: TimelineTransitionOverlaysProps) {
|
|
28
26
|
const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
|
|
29
|
-
const seams = deriveTimelineTransitionSeams(transitionElements).filter(
|
|
30
|
-
(seam) => seam.incoming.track === track,
|
|
31
|
-
);
|
|
32
27
|
return (
|
|
33
28
|
<>
|
|
34
29
|
<TimelineClipJoins
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
4
4
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
@@ -29,6 +29,16 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Mounts a clip's content only once the timeline is at rest, then keeps it through later scrolls,
|
|
34
|
+
* so a scroll never blanks a picture already on screen and never mounts a screenful of new ones.
|
|
35
|
+
*/
|
|
36
|
+
export function ClipContentOnceShown({ hold, children }: { hold: boolean; children: ReactNode }) {
|
|
37
|
+
const [shown, setShown] = useState(!hold);
|
|
38
|
+
if (!shown && !hold) setShown(true);
|
|
39
|
+
return shown ? children : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
export function renderClipChildren(
|
|
33
43
|
element: TimelineElement,
|
|
34
44
|
clipStyle: TrackVisualStyle,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import {
|
|
5
5
|
buildTimelineMeta,
|
|
6
|
-
resolveRenderClipContent,
|
|
7
6
|
resolveResizingElementIds,
|
|
8
7
|
shouldIgnoreTimelinePointerDown,
|
|
9
8
|
type TimelineMetaBuilderInputs,
|
|
@@ -85,12 +84,6 @@ describe("buildTimelineMeta", () => {
|
|
|
85
84
|
});
|
|
86
85
|
|
|
87
86
|
describe("timeline provider branch helpers", () => {
|
|
88
|
-
it("resolves virtualized clip content", () => {
|
|
89
|
-
const render = () => null;
|
|
90
|
-
expect(resolveRenderClipContent(true, true, render)).toBeUndefined();
|
|
91
|
-
expect(resolveRenderClipContent(false, true, render)).toBe(render);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
87
|
it("resolves resizing element ids", () => {
|
|
95
88
|
expect(resolveResizingElementIds(null)).toBeUndefined();
|
|
96
89
|
expect(resolveResizingElementIds({ element: { id: "clip" } } as never)).toEqual(["clip"]);
|
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
TimelineViewportProps,
|
|
5
5
|
} from "./TimelineProvider";
|
|
6
6
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
7
|
-
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
8
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
9
8
|
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
10
9
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
@@ -34,14 +33,6 @@ export function resolveResizingElementIds(
|
|
|
34
33
|
return undefined;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
export function resolveRenderClipContent(
|
|
38
|
-
rowVirtualizationActive: boolean,
|
|
39
|
-
isScrolling: boolean,
|
|
40
|
-
renderClipContent: TimelineLaneBaseProps["renderClipContent"],
|
|
41
|
-
): TimelineLaneBaseProps["renderClipContent"] {
|
|
42
|
-
return rowVirtualizationActive && isScrolling ? undefined : renderClipContent;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
36
|
export function shouldIgnoreTimelinePointerDown(target: EventTarget | null): boolean {
|
|
46
37
|
return target instanceof Element && target.closest("button, input, select, a") !== null;
|
|
47
38
|
}
|