@hyperframes/studio 0.8.59 → 0.8.61
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-BBub3ZdI.js → hyperframes-player-nmVkPQza.js} +1 -1
- package/dist/assets/{index-CI7WON-E.js → index-2-HsQB2C.js} +1 -1
- package/dist/assets/{index-D1KNPijK.js → index-BK36b0XY.js} +162 -162
- package/dist/assets/{index-BxZ5Cw5w.js → index-COG0K-CQ.js} +1 -1
- package/dist/assets/{index-DZBHG9TW.css → index-DS0kLbuM.css} +1 -1
- package/dist/index.d.ts +5 -6
- package/dist/index.html +2 -2
- package/dist/index.js +4977 -4273
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +5 -10
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +278 -7
- package/src/components/nle/AudioMeterStrip.tsx +264 -47
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
- package/src/hooks/useRenderClipContent.test.ts +33 -4
- package/src/hooks/useRenderClipContent.ts +7 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
- package/src/hooks/useSdkSession.ts +84 -14
- package/src/hooks/useStudioSdkSessions.ts +8 -1
- package/src/player/components/AudioWaveform.test.tsx +53 -1
- package/src/player/components/AudioWaveform.tsx +124 -65
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +18 -15
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +11 -4
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.tsx +64 -31
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelineLanes.tsx +15 -8
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/TimelineTransitionBadge.tsx +59 -0
- package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
- package/src/player/components/VideoThumbnail.test.tsx +23 -12
- package/src/player/components/VideoThumbnail.tsx +86 -32
- package/src/player/components/audioClipLink.test.ts +34 -0
- package/src/player/components/audioClipLink.ts +33 -0
- package/src/player/components/audioWaveformPeaks.test.ts +32 -0
- package/src/player/components/audioWaveformPeaks.ts +46 -0
- package/src/player/components/thumbnailUtils.test.ts +17 -0
- package/src/player/components/thumbnailUtils.ts +7 -0
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +18 -11
- package/src/player/components/timelineTransitionSeams.test.ts +48 -0
- package/src/player/components/timelineTransitionSeams.ts +36 -0
- package/src/player/components/timelineZones.test.ts +18 -0
- package/src/player/components/timelineZones.ts +4 -1
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +24 -2
- package/src/player/lib/timelineIframeHelpers.ts +7 -0
- package/src/player/lib/timelineTransitionMetadata.ts +52 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -1
- package/src/player/store/timelineElement.ts +1 -0
- package/src/styles/hex-baseline.json +3 -22
- package/src/styles/studio.css +126 -46
- package/src/styles/theme.css +113 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterVisibility.ts +2 -2
- package/src/utils/sdkCutover.test.ts +102 -1
- package/src/utils/sdkCutover.ts +16 -21
- package/src/utils/sdkEditTransaction.ts +50 -1
- package/src/utils/studioUiPreferences.ts +1 -1
|
@@ -4,6 +4,7 @@ import { createElement } from "react";
|
|
|
4
4
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
5
5
|
import type { TimelineElement } from "../player";
|
|
6
6
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
7
|
+
import { audioPillFlags } from "../player/components/audioClipLink";
|
|
7
8
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
8
9
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
9
10
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
@@ -70,6 +71,7 @@ function renderAudioClip(
|
|
|
70
71
|
sessionEpoch: number,
|
|
71
72
|
labelColor: string,
|
|
72
73
|
context: TimelineClipRenderContext,
|
|
74
|
+
elements: readonly TimelineElement[],
|
|
73
75
|
): ReactNode {
|
|
74
76
|
const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
|
|
75
77
|
const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
|
|
@@ -91,6 +93,7 @@ function renderAudioClip(
|
|
|
91
93
|
projectId: pid,
|
|
92
94
|
sessionEpoch,
|
|
93
95
|
priority: context.priority,
|
|
96
|
+
...audioPillFlags(el, elements),
|
|
94
97
|
});
|
|
95
98
|
}
|
|
96
99
|
|
|
@@ -112,6 +115,7 @@ export function useRenderClipContent({
|
|
|
112
115
|
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
|
|
113
116
|
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
114
117
|
const contentRevision = usePlayerStore((s) => s.thumbnailContentRevision);
|
|
118
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
115
119
|
return useCallback(
|
|
116
120
|
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
|
|
117
121
|
// fallow-ignore-next-line complexity
|
|
@@ -127,7 +131,7 @@ export function useRenderClipContent({
|
|
|
127
131
|
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
128
132
|
if (effectiveMode === "hidden") {
|
|
129
133
|
return el.tag === "audio"
|
|
130
|
-
? renderAudioClip(el, pid, sessionEpoch, style.label, context)
|
|
134
|
+
? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
|
|
131
135
|
: null;
|
|
132
136
|
}
|
|
133
137
|
|
|
@@ -165,7 +169,7 @@ export function useRenderClipContent({
|
|
|
165
169
|
// activePreviewUrl thumbnail branch; audio rows need waveform data, not a
|
|
166
170
|
// captured frame from the currently drilled composition preview.
|
|
167
171
|
if (el.tag === "audio") {
|
|
168
|
-
return renderAudioClip(el, pid, sessionEpoch, style.label, context);
|
|
172
|
+
return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
// When drilled into a composition, render all inner elements via
|
|
@@ -220,7 +224,6 @@ export function useRenderClipContent({
|
|
|
220
224
|
projectId: pid,
|
|
221
225
|
sessionEpoch,
|
|
222
226
|
priority: context.priority,
|
|
223
|
-
rich: context.rich,
|
|
224
227
|
});
|
|
225
228
|
}
|
|
226
229
|
|
|
@@ -252,6 +255,7 @@ export function useRenderClipContent({
|
|
|
252
255
|
effectiveMode,
|
|
253
256
|
sessionEpoch,
|
|
254
257
|
contentRevision,
|
|
258
|
+
elements,
|
|
255
259
|
],
|
|
256
260
|
);
|
|
257
261
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
1
2
|
// @vitest-environment happy-dom
|
|
2
3
|
import { act } from "react";
|
|
3
4
|
import { createRoot } from "react-dom/client";
|
|
@@ -263,6 +264,64 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
263
264
|
stage: "read",
|
|
264
265
|
reason: "http_error",
|
|
265
266
|
status: 404,
|
|
267
|
+
why: undefined,
|
|
268
|
+
});
|
|
269
|
+
await act(async () => root.unmount());
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// `why` distinguishes the studio-server route's own 403/404 causes (the
|
|
273
|
+
// project's folder having been renamed or deleted out from under a running
|
|
274
|
+
// server, vs. a NUL byte, vs. a path escaping the project) from a bare
|
|
275
|
+
// status code. Optional and best-effort: an older server or a non-JSON body
|
|
276
|
+
// just omits it, which must not crash the read.
|
|
277
|
+
it("carries the server's why when the error body has one", async () => {
|
|
278
|
+
vi.stubGlobal(
|
|
279
|
+
"fetch",
|
|
280
|
+
vi.fn(
|
|
281
|
+
async () =>
|
|
282
|
+
({
|
|
283
|
+
ok: false,
|
|
284
|
+
status: 404,
|
|
285
|
+
json: async () => ({ error: "not found", why: "project_dir_missing" }),
|
|
286
|
+
}) as Response,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
const root = createRoot(document.createElement("div"));
|
|
290
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
291
|
+
await flushAsyncEffects();
|
|
292
|
+
|
|
293
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
294
|
+
stage: "read",
|
|
295
|
+
reason: "http_error",
|
|
296
|
+
status: 404,
|
|
297
|
+
why: "project_dir_missing",
|
|
298
|
+
});
|
|
299
|
+
await act(async () => root.unmount());
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("stays quiet about why when the response body cannot be parsed", async () => {
|
|
303
|
+
vi.stubGlobal(
|
|
304
|
+
"fetch",
|
|
305
|
+
vi.fn(
|
|
306
|
+
async () =>
|
|
307
|
+
({
|
|
308
|
+
ok: false,
|
|
309
|
+
status: 500,
|
|
310
|
+
json: async () => {
|
|
311
|
+
throw new Error("not json");
|
|
312
|
+
},
|
|
313
|
+
}) as unknown as Response,
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
const root = createRoot(document.createElement("div"));
|
|
317
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
318
|
+
await flushAsyncEffects();
|
|
319
|
+
|
|
320
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
321
|
+
stage: "read",
|
|
322
|
+
reason: "http_error",
|
|
323
|
+
status: 500,
|
|
324
|
+
why: undefined,
|
|
266
325
|
});
|
|
267
326
|
await act(async () => root.unmount());
|
|
268
327
|
});
|
|
@@ -287,6 +346,8 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
287
346
|
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
288
347
|
stage: "read",
|
|
289
348
|
reason: "network",
|
|
349
|
+
elapsed_ms: expect.any(Number),
|
|
350
|
+
hidden: expect.any(Boolean),
|
|
290
351
|
});
|
|
291
352
|
expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
292
353
|
stage: "open",
|
|
@@ -295,6 +356,70 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
295
356
|
await act(async () => root.unmount());
|
|
296
357
|
});
|
|
297
358
|
|
|
359
|
+
// `elapsed_ms` is the discriminator this event exists for: a policy block
|
|
360
|
+
// (CSP, PNA, an extension rewriting fetch) rejects near-instantly; a dropped
|
|
361
|
+
// connection (the tab or `preview` server going away mid-flight) rejects
|
|
362
|
+
// after a real delay. Both look identical without the timing.
|
|
363
|
+
it("times the network rejection from fetch start to reject", async () => {
|
|
364
|
+
// Fake only the clock `performance.now` reads, not timers: other code in
|
|
365
|
+
// this tree (React's own scheduler included) also calls `performance.now`,
|
|
366
|
+
// so pinning return values by call order (`mockReturnValueOnce`) is
|
|
367
|
+
// unreliable — a real run showed React consuming the queued values first.
|
|
368
|
+
// A fake clock that only advances when we say so sidesteps that entirely.
|
|
369
|
+
vi.useFakeTimers({ toFake: ["performance"] });
|
|
370
|
+
let rejectFetch: ((error: unknown) => void) | undefined;
|
|
371
|
+
vi.stubGlobal(
|
|
372
|
+
"fetch",
|
|
373
|
+
vi.fn(
|
|
374
|
+
() =>
|
|
375
|
+
new Promise((_resolve, reject) => {
|
|
376
|
+
rejectFetch = reject;
|
|
377
|
+
}),
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
const root = createRoot(document.createElement("div"));
|
|
381
|
+
// The effect runs synchronously up to `await fetch(...)`, capturing
|
|
382
|
+
// `fetchStarted` at the current (fake) clock value before this returns.
|
|
383
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
384
|
+
await vi.advanceTimersByTimeAsync(3_200);
|
|
385
|
+
await act(async () => rejectFetch?.(new TypeError("Failed to fetch")));
|
|
386
|
+
|
|
387
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
388
|
+
stage: "read",
|
|
389
|
+
reason: "network",
|
|
390
|
+
elapsed_ms: 3_200,
|
|
391
|
+
hidden: false,
|
|
392
|
+
});
|
|
393
|
+
vi.useRealTimers();
|
|
394
|
+
await act(async () => root.unmount());
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("records the page as hidden when the rejection lands after the tab is backgrounded", async () => {
|
|
398
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
|
399
|
+
Object.defineProperty(document, "visibilityState", {
|
|
400
|
+
configurable: true,
|
|
401
|
+
get: () => "hidden",
|
|
402
|
+
});
|
|
403
|
+
vi.stubGlobal(
|
|
404
|
+
"fetch",
|
|
405
|
+
vi.fn(async () => {
|
|
406
|
+
throw new TypeError("Failed to fetch");
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
const root = createRoot(document.createElement("div"));
|
|
410
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
411
|
+
await flushAsyncEffects();
|
|
412
|
+
|
|
413
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
414
|
+
stage: "read",
|
|
415
|
+
reason: "network",
|
|
416
|
+
elapsed_ms: expect.any(Number),
|
|
417
|
+
hidden: true,
|
|
418
|
+
});
|
|
419
|
+
await act(async () => root.unmount());
|
|
420
|
+
if (originalDescriptor) Object.defineProperty(document, "visibilityState", originalDescriptor);
|
|
421
|
+
});
|
|
422
|
+
|
|
298
423
|
it("separates an unexpected response shape from a failed request", async () => {
|
|
299
424
|
vi.stubGlobal(
|
|
300
425
|
"fetch",
|
|
@@ -324,13 +449,67 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
324
449
|
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
325
450
|
await flushAsyncEffects();
|
|
326
451
|
|
|
452
|
+
// No fileTree passed (this Probe doesn't have one) — path_in_tree stays
|
|
453
|
+
// null, same as before the tree loads. See the tree-aware tests below.
|
|
327
454
|
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
328
455
|
stage: "read",
|
|
329
456
|
reason: "absent_or_empty",
|
|
457
|
+
path_in_tree: null,
|
|
330
458
|
});
|
|
331
459
|
await act(async () => root.unmount());
|
|
332
460
|
});
|
|
333
461
|
|
|
462
|
+
// The graveyard-refuted fix's proposed next step: instrument, don't act.
|
|
463
|
+
// `path_in_tree` separates "genuinely not in the loaded tree" from
|
|
464
|
+
// "concurrent/duplicate open" without deciding anything on Studio's behalf.
|
|
465
|
+
// Two separate mounts (not a re-render of one): fileTree/fileTreeLoaded are
|
|
466
|
+
// deliberately outside the open effect's deps — re-running the whole
|
|
467
|
+
// open/dispose cycle on every tree refresh would drop a perfectly good
|
|
468
|
+
// session far more often than the tree actually changes — so a prop-only
|
|
469
|
+
// change on an already-mounted probe would never re-fire the read this
|
|
470
|
+
// event comes from.
|
|
471
|
+
it("reports path_in_tree against the caller's loaded file tree", async () => {
|
|
472
|
+
vi.stubGlobal(
|
|
473
|
+
"fetch",
|
|
474
|
+
vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
|
|
475
|
+
);
|
|
476
|
+
function TreeProbe({
|
|
477
|
+
fileTree,
|
|
478
|
+
fileTreeLoaded,
|
|
479
|
+
}: {
|
|
480
|
+
fileTree: string[];
|
|
481
|
+
fileTreeLoaded: boolean;
|
|
482
|
+
}) {
|
|
483
|
+
useSdkSession("project-a", "index.html", fileTree, fileTreeLoaded);
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const rootA = createRoot(document.createElement("div"));
|
|
488
|
+
await act(async () =>
|
|
489
|
+
rootA.render(<TreeProbe fileTree={["other.html"]} fileTreeLoaded={true} />),
|
|
490
|
+
);
|
|
491
|
+
await flushAsyncEffects();
|
|
492
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
493
|
+
stage: "read",
|
|
494
|
+
reason: "absent_or_empty",
|
|
495
|
+
path_in_tree: false,
|
|
496
|
+
});
|
|
497
|
+
await act(async () => rootA.unmount());
|
|
498
|
+
|
|
499
|
+
trackMock.mockClear();
|
|
500
|
+
const rootB = createRoot(document.createElement("div"));
|
|
501
|
+
await act(async () =>
|
|
502
|
+
rootB.render(<TreeProbe fileTree={["index.html"]} fileTreeLoaded={true} />),
|
|
503
|
+
);
|
|
504
|
+
await flushAsyncEffects();
|
|
505
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
506
|
+
stage: "read",
|
|
507
|
+
reason: "absent_or_empty",
|
|
508
|
+
path_in_tree: true,
|
|
509
|
+
});
|
|
510
|
+
await act(async () => rootB.unmount());
|
|
511
|
+
});
|
|
512
|
+
|
|
334
513
|
it("reports a parse failure with its message", async () => {
|
|
335
514
|
vi.stubGlobal(
|
|
336
515
|
"fetch",
|
|
@@ -26,13 +26,22 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
|
26
26
|
* "Failed to fetch", "Load failed", "NetworkError when attempting to fetch
|
|
27
27
|
* resource." — so the class was a network problem filed under a parser one, and
|
|
28
28
|
* unaddressable in that bucket.
|
|
29
|
+
*
|
|
30
|
+
* `network` carries `elapsedMs` (time from fetch start to rejection) and
|
|
31
|
+
* `hidden` (`document.visibilityState` at the moment of rejection) because
|
|
32
|
+
* "the fetch rejected" alone conflates two very different situations: a
|
|
33
|
+
* closing tab or a server that exited under a live one (rejects after a real
|
|
34
|
+
* delay, `hidden` often true by the time it lands) versus a request blocked
|
|
35
|
+
* before it left the browser — CSP `connect-src`, Private Network Access, an
|
|
36
|
+
* extension rewriting `fetch` (rejects near-instantly, tab stays visible and
|
|
37
|
+
* alive). Telemetry before this change could not tell the two apart.
|
|
29
38
|
*/
|
|
30
39
|
type ProjectFileReadFailure =
|
|
31
40
|
| { ok: false; reason: "unsafe_path" }
|
|
32
|
-
| { ok: false; reason: "http_error"; status: number }
|
|
41
|
+
| { ok: false; reason: "http_error"; status: number; why?: string }
|
|
33
42
|
| { ok: false; reason: "missing_content" }
|
|
34
43
|
| { ok: false; reason: "absent_or_empty" }
|
|
35
|
-
| { ok: false; reason: "network" };
|
|
44
|
+
| { ok: false; reason: "network"; elapsedMs: number; hidden: boolean };
|
|
36
45
|
|
|
37
46
|
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
38
47
|
|
|
@@ -54,16 +63,61 @@ type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFail
|
|
|
54
63
|
* called from is a long pre-existing async body already near the complexity
|
|
55
64
|
* threshold, and this branch is one coherent unit.
|
|
56
65
|
*/
|
|
57
|
-
function reportReadFailure(
|
|
66
|
+
function reportReadFailure(
|
|
67
|
+
read: ProjectFileReadFailure,
|
|
68
|
+
projectId: string,
|
|
69
|
+
pathInTree: boolean | null,
|
|
70
|
+
): string | null {
|
|
58
71
|
trackStudioEvent("sdk_session_unavailable", {
|
|
59
72
|
stage: "read",
|
|
60
73
|
reason: read.reason,
|
|
61
|
-
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
74
|
+
...(read.reason === "http_error" ? { status: read.status, why: read.why } : {}),
|
|
75
|
+
...(read.reason === "network" ? { elapsed_ms: read.elapsedMs, hidden: read.hidden } : {}),
|
|
76
|
+
// Only meaningful for `absent_or_empty` — the graveyard-refuted "clear
|
|
77
|
+
// activeCompPath when it's not in the tree" fix's proposed next step,
|
|
78
|
+
// scoped to instrumentation only. `null` while the tree hasn't loaded
|
|
79
|
+
// yet: a false-negative there would read as "genuinely absent" when it is
|
|
80
|
+
// really "haven't looked".
|
|
81
|
+
...(read.reason === "absent_or_empty" ? { path_in_tree: pathInTree } : {}),
|
|
62
82
|
});
|
|
63
83
|
if (read.reason !== "http_error") return null;
|
|
64
84
|
return read.status === 404 ? projectId : null;
|
|
65
85
|
}
|
|
66
86
|
|
|
87
|
+
// The request never produced a response: offline, the dev server gone, a
|
|
88
|
+
// CSP/Private-Network-Access block, or an extension rewriting fetch. Called
|
|
89
|
+
// from the fetch's own catch so it is reported as a READ failure — left to
|
|
90
|
+
// propagate, the effect's outer catch reported it as `stage: "open"`, which
|
|
91
|
+
// claims the composition failed to parse. Every `stage: open` event before
|
|
92
|
+
// this change was this branch, so the label made the largest class
|
|
93
|
+
// unaddressable.
|
|
94
|
+
//
|
|
95
|
+
// `elapsedMs` and `hidden` (read synchronously, right at the moment of
|
|
96
|
+
// rejection, so they survive whenever the event itself does) are the
|
|
97
|
+
// discriminator between "blocked before send" (near-zero elapsed) and "the
|
|
98
|
+
// tab/server went away mid-flight" (real elapsed, often already hidden) —
|
|
99
|
+
// see the type's doc comment.
|
|
100
|
+
function networkReadFailure(
|
|
101
|
+
fetchStarted: number,
|
|
102
|
+
): Extract<ProjectFileReadFailure, { reason: "network" }> {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: "network",
|
|
106
|
+
elapsedMs: Math.round(performance.now() - fetchStarted),
|
|
107
|
+
hidden: typeof document !== "undefined" && document.visibilityState === "hidden",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `why` distinguishes the studio-server route's own 403/404 causes (a NUL
|
|
112
|
+
// byte, a path escaping the project, or — the one that used to read as a
|
|
113
|
+
// plain path-traversal 403 — this project's folder having been renamed or
|
|
114
|
+
// deleted out from under a still-running server) that a bare status code
|
|
115
|
+
// cannot. Best-effort: an older server or a non-JSON error body just omits it.
|
|
116
|
+
async function httpReadFailureWhy(res: Response): Promise<string | undefined> {
|
|
117
|
+
const body = (await res.json().catch(() => null)) as { why?: unknown } | null;
|
|
118
|
+
return typeof body?.why === "string" ? body.why : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
67
121
|
/**
|
|
68
122
|
* Read a project file's content (optional read — a missing file is not an
|
|
69
123
|
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
@@ -80,20 +134,18 @@ async function readProjectFileOptional(
|
|
|
80
134
|
// already confines both values to single segments of this same-origin URL.
|
|
81
135
|
if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
|
|
82
136
|
let res: Response;
|
|
137
|
+
const fetchStarted = performance.now();
|
|
83
138
|
try {
|
|
84
139
|
res = await fetch(
|
|
85
140
|
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
86
141
|
);
|
|
87
142
|
} catch {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// branch, so the label made the largest class unaddressable.
|
|
94
|
-
return { ok: false, reason: "network" };
|
|
143
|
+
return networkReadFailure(fetchStarted);
|
|
144
|
+
}
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
const why = await httpReadFailureWhy(res);
|
|
147
|
+
return { ok: false, reason: "http_error", status: res.status, ...(why ? { why } : {}) };
|
|
95
148
|
}
|
|
96
|
-
if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
|
|
97
149
|
const data = (await res.json()) as { content?: string };
|
|
98
150
|
// `optional=1` answers a missing file with 200 + `content: ""`, so a
|
|
99
151
|
// non-string here means a response shape we did not expect, not absence.
|
|
@@ -224,6 +276,13 @@ function disposeSdkSession(session: Composition): void {
|
|
|
224
276
|
export function useSdkSession(
|
|
225
277
|
projectId: string | null,
|
|
226
278
|
activeCompPath: string | null,
|
|
279
|
+
// Optional: only the app's primary session (App.tsx, wired to
|
|
280
|
+
// useFileManager's tree) can answer `path_in_tree` on an `absent_or_empty`
|
|
281
|
+
// read. A secondary session opened for a promote/bind target
|
|
282
|
+
// (DesignPanelPromoteProvider) has no tree of its own to check against and
|
|
283
|
+
// reports `null`, same as before the tree loads.
|
|
284
|
+
fileTree: readonly string[] = [],
|
|
285
|
+
fileTreeLoaded = false,
|
|
227
286
|
): SdkSessionHandle {
|
|
228
287
|
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
|
229
288
|
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
|
@@ -276,7 +335,8 @@ export function useSdkSession(
|
|
|
276
335
|
.then(async (read) => {
|
|
277
336
|
if (cancelled) return;
|
|
278
337
|
if (!read.ok) {
|
|
279
|
-
|
|
338
|
+
const pathInTree = fileTreeLoaded ? fileTree.includes(activeCompPath) : null;
|
|
339
|
+
setUnreachableProject(reportReadFailure(read, projectId, pathInTree));
|
|
280
340
|
return;
|
|
281
341
|
}
|
|
282
342
|
setUnreachableProject(null);
|
|
@@ -305,7 +365,11 @@ export function useSdkSession(
|
|
|
305
365
|
)
|
|
306
366
|
) {
|
|
307
367
|
disposeSdkSession(comp);
|
|
308
|
-
|
|
368
|
+
// Not a failure: project/path/reloadToken moved on while this open was
|
|
369
|
+
// in flight, and the effect that owns the new identity already opened
|
|
370
|
+
// (or is opening) its own session. Emitting this as `sdk_session_unavailable`
|
|
371
|
+
// counted a benign race as a broken precondition on the cutover dashboard.
|
|
372
|
+
trackStudioEvent("sdk_session_superseded", {});
|
|
309
373
|
return;
|
|
310
374
|
}
|
|
311
375
|
const displaced = ownedSessionRef.current;
|
|
@@ -343,6 +407,12 @@ export function useSdkSession(
|
|
|
343
407
|
disposeSdkSession(owned.session);
|
|
344
408
|
}
|
|
345
409
|
};
|
|
410
|
+
// fileTree/fileTreeLoaded deliberately excluded: they only annotate a
|
|
411
|
+
// read-failure event fired from this same effect run (the diagnostic
|
|
412
|
+
// "was the path in the last-loaded tree" snapshot), and re-running the
|
|
413
|
+
// whole open/dispose cycle on every tree refresh would drop and reopen a
|
|
414
|
+
// perfectly good session far more often than the tree actually changes.
|
|
415
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
346
416
|
}, [projectId, activeCompPath, reloadToken]);
|
|
347
417
|
|
|
348
418
|
const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
|
|
@@ -18,12 +18,19 @@ export function useStudioSdkSessions(
|
|
|
18
18
|
projectId: string | null,
|
|
19
19
|
activeCompPath: string | null,
|
|
20
20
|
masterCompPath: string | null,
|
|
21
|
+
fileTree: readonly string[] = [],
|
|
22
|
+
fileTreeLoaded = false,
|
|
21
23
|
) {
|
|
22
24
|
// On the master view (no explicit comp) the schema panels target the project's
|
|
23
25
|
// resolved main composition — the first `.html` in the tree, not a hardcoded
|
|
24
26
|
// "index.html" that may not exist. `null` when the project has no composition
|
|
25
27
|
// yet, which correctly leaves the session (and the panels) empty.
|
|
26
|
-
const sdkHandle = useSdkSession(
|
|
28
|
+
const sdkHandle = useSdkSession(
|
|
29
|
+
projectId,
|
|
30
|
+
activeCompPath ?? masterCompPath,
|
|
31
|
+
fileTree,
|
|
32
|
+
fileTreeLoaded,
|
|
33
|
+
);
|
|
27
34
|
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
|
|
28
35
|
useEffect(() => {
|
|
29
36
|
usePreviewVariablesStore.getState().setValues(null);
|
|
@@ -11,7 +11,7 @@ vi.mock("../../hooks/useThumbnailLease", () => ({
|
|
|
11
11
|
useThumbnailLease: leaseSpy,
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
|
-
import { AudioWaveform } from "./AudioWaveform";
|
|
14
|
+
import { AudioWaveform, drawWaveformCanvas } from "./AudioWaveform";
|
|
15
15
|
|
|
16
16
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
17
|
|
|
@@ -21,6 +21,30 @@ afterEach(() => {
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
describe("AudioWaveform", () => {
|
|
24
|
+
it("paints a baseline and peak bar for every mapped waveform bin", () => {
|
|
25
|
+
const fillRect = vi.fn();
|
|
26
|
+
const context = {
|
|
27
|
+
scale: vi.fn(),
|
|
28
|
+
clearRect: vi.fn(),
|
|
29
|
+
fillRect,
|
|
30
|
+
fillStyle: "",
|
|
31
|
+
} as unknown as CanvasRenderingContext2D;
|
|
32
|
+
const canvas = document.createElement("canvas");
|
|
33
|
+
Object.defineProperties(canvas, {
|
|
34
|
+
clientWidth: { value: 6 },
|
|
35
|
+
clientHeight: { value: 20 },
|
|
36
|
+
});
|
|
37
|
+
vi.spyOn(canvas, "getContext").mockReturnValue(context);
|
|
38
|
+
drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1);
|
|
39
|
+
expect(fillRect).toHaveBeenCalledTimes(4);
|
|
40
|
+
expect(fillRect.mock.calls.map(([x, y, width, height]) => [x, y, width, height])).toEqual([
|
|
41
|
+
[0, 18, 3, 2],
|
|
42
|
+
[0, 15, 3, 5],
|
|
43
|
+
[3, 18, 3, 2],
|
|
44
|
+
[3, 0, 3, 20],
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
24
48
|
it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
|
|
25
49
|
const host = document.createElement("div");
|
|
26
50
|
document.body.append(host);
|
|
@@ -50,4 +74,32 @@ describe("AudioWaveform", () => {
|
|
|
50
74
|
|
|
51
75
|
act(() => root.unmount());
|
|
52
76
|
});
|
|
77
|
+
|
|
78
|
+
it("greys the clip in place when muted and draws the parent tick when linked", () => {
|
|
79
|
+
const host = document.createElement("div");
|
|
80
|
+
host.className = "timeline-clip is-audio";
|
|
81
|
+
document.body.append(host);
|
|
82
|
+
const root = createRoot(host);
|
|
83
|
+
|
|
84
|
+
act(() => {
|
|
85
|
+
root.render(
|
|
86
|
+
<AudioWaveform
|
|
87
|
+
audioUrl="/media/voice.wav"
|
|
88
|
+
label=""
|
|
89
|
+
labelColor="#fff"
|
|
90
|
+
projectId="project-a"
|
|
91
|
+
sessionEpoch={1}
|
|
92
|
+
priority="visible"
|
|
93
|
+
muted
|
|
94
|
+
linked
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(host.getAttribute("data-audio-muted")).toBe("true");
|
|
100
|
+
expect(host.querySelector(".timeline-audio-link")).not.toBeNull();
|
|
101
|
+
|
|
102
|
+
act(() => root.unmount());
|
|
103
|
+
expect(host.hasAttribute("data-audio-muted")).toBe(false);
|
|
104
|
+
});
|
|
53
105
|
});
|