@hyperframes/studio 0.8.63 → 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-CjueZL58.js → index-Cs1lQrQ6.js} +142 -142
- package/dist/assets/{index-DCqmNhmi.js → index-a5nEhdAU.js} +1 -1
- package/dist/assets/{index-CISQydDK.js → index-wQFEeTL3.js} +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.html +1 -1
- package/dist/index.js +852 -755
- 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/useSdkSession.lifecycle.test.tsx +78 -1
- package/src/hooks/useSdkSession.ts +75 -13
- 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-FmXtbvEE.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/parsers": "0.8.
|
|
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
|
);
|
|
@@ -440,7 +440,9 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
440
440
|
// so this is what "the composition genuinely is not there" looks like on the
|
|
441
441
|
// wire — previously indistinguishable from a broken request. The shim and a
|
|
442
442
|
// real 0-byte file are the same response, hence the name.
|
|
443
|
-
|
|
443
|
+
// An older server sends no `missing` field, so the combined label stays —
|
|
444
|
+
// rather than guessing one of the two and quietly corrupting the series.
|
|
445
|
+
it("keeps the combined label when the server does not say which empty this is", async () => {
|
|
444
446
|
vi.stubGlobal(
|
|
445
447
|
"fetch",
|
|
446
448
|
vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
|
|
@@ -459,6 +461,81 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
459
461
|
await act(async () => root.unmount());
|
|
460
462
|
});
|
|
461
463
|
|
|
464
|
+
// `missing: true` is the route's shim — nothing resolved at that path.
|
|
465
|
+
it("reports a file the server could not find as absent", async () => {
|
|
466
|
+
vi.stubGlobal(
|
|
467
|
+
"fetch",
|
|
468
|
+
vi.fn(
|
|
469
|
+
async () => ({ ok: true, json: async () => ({ content: "", missing: true }) }) as Response,
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
const root = createRoot(document.createElement("div"));
|
|
473
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
474
|
+
await flushAsyncEffects();
|
|
475
|
+
|
|
476
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
477
|
+
stage: "read",
|
|
478
|
+
reason: "absent",
|
|
479
|
+
path_in_tree: null,
|
|
480
|
+
});
|
|
481
|
+
await act(async () => root.unmount());
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// `missing: false` with empty content is a real 0-byte file on disk — a
|
|
485
|
+
// placeholder somebody created and has not written yet, not a bad path.
|
|
486
|
+
it("reports a real zero-byte file separately from an absent one", async () => {
|
|
487
|
+
vi.stubGlobal(
|
|
488
|
+
"fetch",
|
|
489
|
+
vi.fn(
|
|
490
|
+
async () => ({ ok: true, json: async () => ({ content: "", missing: false }) }) as Response,
|
|
491
|
+
),
|
|
492
|
+
);
|
|
493
|
+
const root = createRoot(document.createElement("div"));
|
|
494
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
495
|
+
await flushAsyncEffects();
|
|
496
|
+
|
|
497
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
498
|
+
stage: "read",
|
|
499
|
+
reason: "empty_file",
|
|
500
|
+
path_in_tree: null,
|
|
501
|
+
});
|
|
502
|
+
await act(async () => root.unmount());
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// A 200 carrying HTML is an SPA fallback or a proxy answering in the route's
|
|
506
|
+
// place. Before this, `res.json()` rejected outside any catch and the outer
|
|
507
|
+
// catch filed it as `stage: "open"` — blaming the user's composition for a
|
|
508
|
+
// response the composition had nothing to do with.
|
|
509
|
+
it("reports a non-JSON 200 as a read failure, not a composition parse failure", async () => {
|
|
510
|
+
vi.stubGlobal(
|
|
511
|
+
"fetch",
|
|
512
|
+
vi.fn(
|
|
513
|
+
async () =>
|
|
514
|
+
({
|
|
515
|
+
ok: true,
|
|
516
|
+
headers: { get: () => "text/html; charset=utf-8" },
|
|
517
|
+
json: async () => {
|
|
518
|
+
throw new SyntaxError(`Unexpected token '<', "<!-- /*!"... is not valid JSON`);
|
|
519
|
+
},
|
|
520
|
+
}) as unknown as Response,
|
|
521
|
+
),
|
|
522
|
+
);
|
|
523
|
+
const root = createRoot(document.createElement("div"));
|
|
524
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
525
|
+
await flushAsyncEffects();
|
|
526
|
+
|
|
527
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
528
|
+
stage: "read",
|
|
529
|
+
reason: "invalid_json",
|
|
530
|
+
content_type: "text/html; charset=utf-8",
|
|
531
|
+
});
|
|
532
|
+
expect(trackMock).not.toHaveBeenCalledWith(
|
|
533
|
+
"sdk_session_unavailable",
|
|
534
|
+
expect.objectContaining({ stage: "open" }),
|
|
535
|
+
);
|
|
536
|
+
await act(async () => root.unmount());
|
|
537
|
+
});
|
|
538
|
+
|
|
462
539
|
// The graveyard-refuted fix's proposed next step: instrument, don't act.
|
|
463
540
|
// `path_in_tree` separates "genuinely not in the loaded tree" from
|
|
464
541
|
// "concurrent/duplicate open" without deciding anything on Studio's behalf.
|
|
@@ -35,16 +35,46 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
|
35
35
|
* before it left the browser — CSP `connect-src`, Private Network Access, an
|
|
36
36
|
* extension rewriting `fetch` (rejects near-instantly, tab stays visible and
|
|
37
37
|
* alive). Telemetry before this change could not tell the two apart.
|
|
38
|
+
*
|
|
39
|
+
* `absent` and `empty_file` split what `absent_or_empty` could not: the route
|
|
40
|
+
* answers `content: ""` both for a file it cannot find (the `optional=1` shim)
|
|
41
|
+
* and for a real 0-byte one, so the single label covered a composition nobody
|
|
42
|
+
* has written yet AND a path that does not resolve on this server. The route
|
|
43
|
+
* now marks the shim with `missing: true`. A server without that field still
|
|
44
|
+
* lands in `absent_or_empty`, so the old series stays honest rather than
|
|
45
|
+
* silently folding into one of the new ones.
|
|
46
|
+
*
|
|
47
|
+
* Measured on 0.8.62 before the split: 75 tabs hit this class and not one of
|
|
48
|
+
* them ever landed an SDK edit afterwards — it is terminal for the tab, while
|
|
49
|
+
* the tab itself keeps playing back and navigating, so nothing surfaces.
|
|
50
|
+
*
|
|
51
|
+
* `invalid_json` is a 200 whose body is not JSON at all — in production, an
|
|
52
|
+
* HTML page (`Unexpected token '<', "<!-- /*!"...`), i.e. an SPA fallback or a
|
|
53
|
+
* proxy answering in the route's place. `res.json()` rejected outside any
|
|
54
|
+
* catch until now, so it escaped this function and the effect's outer catch
|
|
55
|
+
* filed it as `stage: "open"` — which asserts the COMPOSITION failed to parse.
|
|
56
|
+
* Third label to make that same wrong claim, after `network` (#4240) and the
|
|
57
|
+
* fetch-rejection split; this one is the response shape, not the composition.
|
|
38
58
|
*/
|
|
39
59
|
type ProjectFileReadFailure =
|
|
40
60
|
| { ok: false; reason: "unsafe_path" }
|
|
41
61
|
| { ok: false; reason: "http_error"; status: number; why?: string }
|
|
42
62
|
| { ok: false; reason: "missing_content" }
|
|
43
63
|
| { ok: false; reason: "absent_or_empty" }
|
|
64
|
+
| { ok: false; reason: "absent" }
|
|
65
|
+
| { ok: false; reason: "empty_file" }
|
|
66
|
+
| { ok: false; reason: "invalid_json"; contentType: string }
|
|
44
67
|
| { ok: false; reason: "network"; elapsedMs: number; hidden: boolean };
|
|
45
68
|
|
|
46
69
|
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
47
70
|
|
|
71
|
+
/** The three ways a 200 can carry no composition — old combined label plus its split. */
|
|
72
|
+
const EMPTY_READ_REASONS = new Set<ProjectFileReadFailure["reason"]>([
|
|
73
|
+
"absent_or_empty",
|
|
74
|
+
"absent",
|
|
75
|
+
"empty_file",
|
|
76
|
+
]);
|
|
77
|
+
|
|
48
78
|
/**
|
|
49
79
|
* Record a read that produced no usable content, and answer which project — if
|
|
50
80
|
* any — the failure identifies as unreachable.
|
|
@@ -73,12 +103,15 @@ function reportReadFailure(
|
|
|
73
103
|
reason: read.reason,
|
|
74
104
|
...(read.reason === "http_error" ? { status: read.status, why: read.why } : {}),
|
|
75
105
|
...(read.reason === "network" ? { elapsed_ms: read.elapsedMs, hidden: read.hidden } : {}),
|
|
76
|
-
|
|
106
|
+
...(read.reason === "invalid_json" ? { content_type: read.contentType } : {}),
|
|
107
|
+
// Only meaningful for the empty-read reasons — the graveyard-refuted "clear
|
|
77
108
|
// activeCompPath when it's not in the tree" fix's proposed next step,
|
|
78
109
|
// scoped to instrumentation only. `null` while the tree hasn't loaded
|
|
79
110
|
// yet: a false-negative there would read as "genuinely absent" when it is
|
|
80
|
-
// really "haven't looked".
|
|
81
|
-
|
|
111
|
+
// really "haven't looked". Carried on all three so the split keeps the
|
|
112
|
+
// signal that made it worth splitting: every measured case so far is
|
|
113
|
+
// `path_in_tree: true`, a file the tree lists and this read cannot get.
|
|
114
|
+
...(EMPTY_READ_REASONS.has(read.reason) ? { path_in_tree: pathInTree } : {}),
|
|
82
115
|
});
|
|
83
116
|
if (read.reason !== "http_error") return null;
|
|
84
117
|
return read.status === 404 ? projectId : null;
|
|
@@ -118,6 +151,30 @@ async function httpReadFailureWhy(res: Response): Promise<string | undefined> {
|
|
|
118
151
|
return typeof body?.why === "string" ? body.why : undefined;
|
|
119
152
|
}
|
|
120
153
|
|
|
154
|
+
interface ProjectFileBody {
|
|
155
|
+
content?: string;
|
|
156
|
+
/** Present only from a server that separates its shim from a real 0-byte file. */
|
|
157
|
+
missing?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Decide what a 200 with a JSON body actually delivered.
|
|
162
|
+
*
|
|
163
|
+
* `optional=1` answers a missing file with 200 + `content: ""`, so a non-string
|
|
164
|
+
* content is a response shape we did not expect, not absence. An empty string
|
|
165
|
+
* parses into a session with no elements, which declines every edit wholesale —
|
|
166
|
+
* not a session worth opening — and `missing` is the route saying which of the
|
|
167
|
+
* two empties it was. Without that field (older server) the combined label
|
|
168
|
+
* stands rather than guessing one and corrupting the series.
|
|
169
|
+
*/
|
|
170
|
+
function classifyProjectFileBody(data: ProjectFileBody): ProjectFileReadResult {
|
|
171
|
+
if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
|
|
172
|
+
if (data.content !== "") return { ok: true, content: data.content };
|
|
173
|
+
if (data.missing === true) return { ok: false, reason: "absent" };
|
|
174
|
+
if (data.missing === false) return { ok: false, reason: "empty_file" };
|
|
175
|
+
return { ok: false, reason: "absent_or_empty" };
|
|
176
|
+
}
|
|
177
|
+
|
|
121
178
|
/**
|
|
122
179
|
* Read a project file's content (optional read — a missing file is not an
|
|
123
180
|
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
@@ -146,16 +203,21 @@ async function readProjectFileOptional(
|
|
|
146
203
|
const why = await httpReadFailureWhy(res);
|
|
147
204
|
return { ok: false, reason: "http_error", status: res.status, ...(why ? { why } : {}) };
|
|
148
205
|
}
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
206
|
+
// A 200 that is not JSON is a different failure from a JSON body missing its
|
|
207
|
+
// field: it means something other than this route answered — an SPA fallback
|
|
208
|
+
// or a proxy. Left unguarded, it rejected out of this function entirely and
|
|
209
|
+
// the effect's outer catch blamed the composition. See the type's comment.
|
|
210
|
+
let data: ProjectFileBody;
|
|
211
|
+
try {
|
|
212
|
+
data = (await res.json()) as ProjectFileBody;
|
|
213
|
+
} catch {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
reason: "invalid_json",
|
|
217
|
+
contentType: res.headers.get("content-type") ?? "",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return classifyProjectFileBody(data);
|
|
159
221
|
}
|
|
160
222
|
|
|
161
223
|
/**
|
|
@@ -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})`);
|