@hyperframes/studio 0.8.56 → 0.8.58
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-zGyOcwwh.js +464 -0
- package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
- package/dist/index.d.ts +124 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15686 -14882
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -0
- package/src/components/EditorShell.tsx +3 -1
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -15
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/SnapToolbar.test.tsx +25 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +48 -6
- package/src/components/nle/NLEPreview.test.ts +67 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.tsx +4 -16
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/previewReadOnly.test.tsx +1 -0
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/hooks/useCompositionDimensions.test.tsx +37 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
- package/src/hooks/useSdkSession.ts +77 -14
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +21 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +175 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -572
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
- package/src/player/index.ts +7 -0
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
6
|
import { useServerConnection } from "./useServerConnection";
|
|
6
7
|
|
|
8
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
9
|
+
|
|
7
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
11
|
|
|
9
12
|
const captured: { projectId: string | null } = { projectId: null };
|
|
@@ -55,6 +58,7 @@ describe("useServerConnection hash project id", () => {
|
|
|
55
58
|
|
|
56
59
|
afterEach(() => {
|
|
57
60
|
vi.unstubAllGlobals();
|
|
61
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
58
62
|
});
|
|
59
63
|
|
|
60
64
|
it("keeps a hash id the server can resolve", async () => {
|
|
@@ -92,3 +96,302 @@ describe("useServerConnection hash project id", () => {
|
|
|
92
96
|
await act(async () => root.unmount());
|
|
93
97
|
});
|
|
94
98
|
});
|
|
99
|
+
|
|
100
|
+
/** Per-project status, so one test can hold two projects in different states. */
|
|
101
|
+
function stubFetchByProject(statuses: Record<string, number | "reject">) {
|
|
102
|
+
vi.stubGlobal(
|
|
103
|
+
"fetch",
|
|
104
|
+
vi.fn(async (url: string) => {
|
|
105
|
+
if (url === "/api/projects") {
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
json: async () => ({ projects: [{ id: "real-project" }] }),
|
|
109
|
+
} as Response;
|
|
110
|
+
}
|
|
111
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
112
|
+
const status = statuses[id];
|
|
113
|
+
if (status === "reject") throw new TypeError("Failed to fetch");
|
|
114
|
+
if (status === undefined) return { ok: false, status: 404 } as Response;
|
|
115
|
+
return { ok: status === 200, status } as Response;
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function changeHash(hash: string) {
|
|
121
|
+
await act(async () => {
|
|
122
|
+
window.location.hash = hash;
|
|
123
|
+
});
|
|
124
|
+
await flush();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Change the hash WITHOUT the test itself pushing or firing anything.
|
|
129
|
+
*
|
|
130
|
+
* `location.hash =` does both, which makes "did the CODE push?" unanswerable —
|
|
131
|
+
* the test's own navigation moves history.length either way. replaceState does
|
|
132
|
+
* neither, so the dispatch is explicit and any growth after it is the hook's.
|
|
133
|
+
*/
|
|
134
|
+
async function replaceHashAndDispatch(hash: string) {
|
|
135
|
+
await act(async () => {
|
|
136
|
+
window.history.replaceState(null, "", hash);
|
|
137
|
+
window.dispatchEvent(new Event("hashchange"));
|
|
138
|
+
});
|
|
139
|
+
await flush();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The hashchange path took any id the URL offered, with no validation — so the
|
|
144
|
+
* mount fix from v0.8.52 could be walked straight around by changing projects.
|
|
145
|
+
* These pin the second door shut.
|
|
146
|
+
*/
|
|
147
|
+
describe("useServerConnection hashchange validation", () => {
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
captured.projectId = null;
|
|
150
|
+
window.location.hash = "";
|
|
151
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
vi.unstubAllGlobals();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("adopts a hash id the server can resolve", async () => {
|
|
159
|
+
stubFetchByProject({ first: 200, second: 200 });
|
|
160
|
+
const root = await renderWithHash("#project/first");
|
|
161
|
+
expect(captured.projectId).toBe("first");
|
|
162
|
+
|
|
163
|
+
await changeHash("#project/second");
|
|
164
|
+
expect(captured.projectId).toBe("second");
|
|
165
|
+
await act(async () => root.unmount());
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("falls back to the first project when the new hash id is gone", async () => {
|
|
169
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
170
|
+
const root = await renderWithHash("#project/first");
|
|
171
|
+
expect(captured.projectId).toBe("first");
|
|
172
|
+
|
|
173
|
+
await changeHash("#project/deleted");
|
|
174
|
+
expect(captured.projectId).toBe("real-project");
|
|
175
|
+
expect(window.location.hash).toContain("real-project");
|
|
176
|
+
await act(async () => root.unmount());
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("keeps the new hash id when the check itself fails", async () => {
|
|
180
|
+
// Same transient-tolerance as the mount path: a blip must not rewrite the
|
|
181
|
+
// user's hash out from under a project that is actually fine.
|
|
182
|
+
stubFetchByProject({ first: 200, flaky: "reject" });
|
|
183
|
+
const root = await renderWithHash("#project/first");
|
|
184
|
+
|
|
185
|
+
await changeHash("#project/flaky");
|
|
186
|
+
expect(captured.projectId).toBe("flaky");
|
|
187
|
+
await act(async () => root.unmount());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("reports the hashchange verdict with its stage", async () => {
|
|
191
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
192
|
+
const root = await renderWithHash("#project/first");
|
|
193
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
194
|
+
|
|
195
|
+
await changeHash("#project/deleted");
|
|
196
|
+
const verdicts = vi
|
|
197
|
+
.mocked(trackStudioEvent)
|
|
198
|
+
.mock.calls.filter(([name]) => name === "project_hash_validated");
|
|
199
|
+
expect(verdicts).toEqual([
|
|
200
|
+
["project_hash_validated", { stage: "hashchange", outcome: "missing", status: 404 }],
|
|
201
|
+
]);
|
|
202
|
+
await act(async () => root.unmount());
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("ignores a verdict that lands after the user moved on", async () => {
|
|
206
|
+
// Two changes in quick succession. The first check resolves late; if it were
|
|
207
|
+
// trusted, it would adopt a project the user already navigated away from.
|
|
208
|
+
let releaseFirst: (() => void) | null = null;
|
|
209
|
+
vi.stubGlobal(
|
|
210
|
+
"fetch",
|
|
211
|
+
vi.fn(async (url: string) => {
|
|
212
|
+
if (url === "/api/projects") {
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
json: async () => ({ projects: [{ id: "real-project" }] }),
|
|
216
|
+
} as Response;
|
|
217
|
+
}
|
|
218
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
219
|
+
if (id === "slow") {
|
|
220
|
+
await new Promise<void>((resolve) => {
|
|
221
|
+
releaseFirst = resolve;
|
|
222
|
+
});
|
|
223
|
+
return { ok: true, status: 200 } as Response;
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, status: 200 } as Response;
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const root = await renderWithHash("#project/slow");
|
|
230
|
+
await flush();
|
|
231
|
+
// Move on before the slow validation answers.
|
|
232
|
+
await act(async () => {
|
|
233
|
+
window.location.hash = "#project/fast";
|
|
234
|
+
});
|
|
235
|
+
await flush();
|
|
236
|
+
expect(captured.projectId).toBe("fast");
|
|
237
|
+
|
|
238
|
+
// Now let the stale check resolve. It must not win.
|
|
239
|
+
await act(async () => {
|
|
240
|
+
releaseFirst?.();
|
|
241
|
+
});
|
|
242
|
+
await flush();
|
|
243
|
+
expect(captured.projectId).toBe("fast");
|
|
244
|
+
await act(async () => root.unmount());
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("falls back to the CURRENT project list, not the one from mount", async () => {
|
|
248
|
+
// The mount fetch is a snapshot. A long-lived tab is exactly the case this
|
|
249
|
+
// path exists for, so re-reading the list is the difference between landing
|
|
250
|
+
// on a project that exists and landing on the one that was first an hour ago.
|
|
251
|
+
let list = [{ id: "old-first" }];
|
|
252
|
+
vi.stubGlobal(
|
|
253
|
+
"fetch",
|
|
254
|
+
vi.fn(async (url: string) => {
|
|
255
|
+
if (url === "/api/projects") {
|
|
256
|
+
return { ok: true, json: async () => ({ projects: list }) } as Response;
|
|
257
|
+
}
|
|
258
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
259
|
+
return { ok: id !== "deleted", status: id === "deleted" ? 404 : 200 } as Response;
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const root = await renderWithHash("#project/old-first");
|
|
264
|
+
expect(captured.projectId).toBe("old-first");
|
|
265
|
+
|
|
266
|
+
list = [{ id: "new-first" }];
|
|
267
|
+
await changeHash("#project/deleted");
|
|
268
|
+
expect(captured.projectId).toBe("new-first");
|
|
269
|
+
await act(async () => root.unmount());
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("re-reads the list on a second dead hash rather than reusing the first answer", async () => {
|
|
273
|
+
// The caching version is invisible with one dead hash — firstProjectId runs
|
|
274
|
+
// once, so a memo and a fresh fetch look identical. Two dead hashes with the
|
|
275
|
+
// list changing in between is what separates them.
|
|
276
|
+
let list = [{ id: "first-a" }];
|
|
277
|
+
vi.stubGlobal(
|
|
278
|
+
"fetch",
|
|
279
|
+
vi.fn(async (url: string) => {
|
|
280
|
+
if (url === "/api/projects") {
|
|
281
|
+
return { ok: true, json: async () => ({ projects: list }) } as Response;
|
|
282
|
+
}
|
|
283
|
+
const id = decodeURIComponent(url.replace("/api/projects/", ""));
|
|
284
|
+
return { ok: id === "alive", status: id === "alive" ? 200 : 404 } as Response;
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const root = await renderWithHash("#project/alive");
|
|
289
|
+
await changeHash("#project/dead-one");
|
|
290
|
+
expect(captured.projectId).toBe("first-a");
|
|
291
|
+
|
|
292
|
+
list = [{ id: "first-b" }];
|
|
293
|
+
await changeHash("#project/dead-two");
|
|
294
|
+
expect(captured.projectId).toBe("first-b");
|
|
295
|
+
await act(async () => root.unmount());
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rewrites a dead hash without adding a history entry", async () => {
|
|
299
|
+
// `location.hash =` pushes, which would leave the dead id one Back away —
|
|
300
|
+
// and Back would then re-validate, fall back and push again, forever.
|
|
301
|
+
stubFetchByProject({ first: 200, deleted: 404 });
|
|
302
|
+
const root = await renderWithHash("#project/first");
|
|
303
|
+
|
|
304
|
+
const before = window.history.length;
|
|
305
|
+
await replaceHashAndDispatch("#project/deleted");
|
|
306
|
+
expect(captured.projectId).toBe("real-project");
|
|
307
|
+
expect(window.history.length).toBe(before);
|
|
308
|
+
await act(async () => root.unmount());
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("rewrites the mount fallback without adding a history entry", async () => {
|
|
312
|
+
// The mount path pushed until now — the same bug from the other end: Back
|
|
313
|
+
// returned to the dead id, which re-validates and pushes again.
|
|
314
|
+
stubFetchByProject({ deleted: 404 });
|
|
315
|
+
window.history.replaceState(null, "", "#project/deleted");
|
|
316
|
+
const before = window.history.length;
|
|
317
|
+
|
|
318
|
+
const root = await renderWithHash("#project/deleted");
|
|
319
|
+
expect(captured.projectId).toBe("real-project");
|
|
320
|
+
expect(window.history.length).toBe(before);
|
|
321
|
+
await act(async () => root.unmount());
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The verdict event. Until it existed, a tab that kept its hash because the
|
|
327
|
+
* check itself failed was indistinguishable in production from one that never
|
|
328
|
+
* ran the check — different causes, different fixes, one silent bucket.
|
|
329
|
+
*/
|
|
330
|
+
describe("project_hash_validated", () => {
|
|
331
|
+
// Mount only: assigning window.location.hash is what happy-dom turns into a
|
|
332
|
+
// hashchange, so the listener reports too. This describe is about the mount
|
|
333
|
+
// path; the hashchange verdicts have their own.
|
|
334
|
+
const verdictCalls = () =>
|
|
335
|
+
vi
|
|
336
|
+
.mocked(trackStudioEvent)
|
|
337
|
+
.mock.calls.filter(
|
|
338
|
+
([name, props]) =>
|
|
339
|
+
name === "project_hash_validated" &&
|
|
340
|
+
(props as { stage?: string } | undefined)?.stage === "mount",
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
beforeEach(() => {
|
|
344
|
+
captured.projectId = null;
|
|
345
|
+
window.location.hash = "";
|
|
346
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
afterEach(() => {
|
|
350
|
+
vi.unstubAllGlobals();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("reports an ok verdict with its status", async () => {
|
|
354
|
+
stubFetch({ status: 200 });
|
|
355
|
+
const root = await renderWithHash("#project/stale-or-not");
|
|
356
|
+
expect(verdictCalls()).toEqual([
|
|
357
|
+
["project_hash_validated", { stage: "mount", outcome: "ok", status: 200 }],
|
|
358
|
+
]);
|
|
359
|
+
await act(async () => root.unmount());
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reports a missing verdict with the 404", async () => {
|
|
363
|
+
stubFetch({ status: 404 });
|
|
364
|
+
const root = await renderWithHash("#project/deleted-project");
|
|
365
|
+
expect(verdictCalls()).toEqual([
|
|
366
|
+
["project_hash_validated", { stage: "mount", outcome: "missing", status: 404 }],
|
|
367
|
+
]);
|
|
368
|
+
await act(async () => root.unmount());
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("reports unknown with no status when the check is rejected", async () => {
|
|
372
|
+
// The point of the event: this case LOOKS like success from outside (the
|
|
373
|
+
// hash is kept) but is a failed check. Only the verdict says so.
|
|
374
|
+
stubFetch("reject");
|
|
375
|
+
const root = await renderWithHash("#project/unreachable-check");
|
|
376
|
+
expect(verdictCalls()).toEqual([
|
|
377
|
+
["project_hash_validated", { stage: "mount", outcome: "unknown" }],
|
|
378
|
+
]);
|
|
379
|
+
await act(async () => root.unmount());
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("reports unknown — not missing — for a 5xx", async () => {
|
|
383
|
+
stubFetch({ status: 500 });
|
|
384
|
+
const root = await renderWithHash("#project/server-erroring");
|
|
385
|
+
expect(verdictCalls()).toEqual([
|
|
386
|
+
["project_hash_validated", { stage: "mount", outcome: "unknown", status: 500 }],
|
|
387
|
+
]);
|
|
388
|
+
await act(async () => root.unmount());
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("emits nothing when there is no hash id to validate", async () => {
|
|
392
|
+
stubFetch({ status: 200 });
|
|
393
|
+
const root = await renderWithHash("");
|
|
394
|
+
expect(verdictCalls()).toEqual([]);
|
|
395
|
+
await act(async () => root.unmount());
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
|
|
3
3
|
import { useMountEffect } from "./useMountEffect";
|
|
4
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
4
5
|
|
|
5
6
|
interface ServerConnectionState {
|
|
6
7
|
projectId: string | null;
|
|
@@ -28,16 +29,65 @@ interface ServerConnectionState {
|
|
|
28
29
|
* `/api/projects` list would be wrong twice over: that list omits session ids
|
|
29
30
|
* (which resolve fine) and skips project dirs without an `index.html`.
|
|
30
31
|
*/
|
|
31
|
-
|
|
32
|
+
type ProjectHashVerdict = {
|
|
33
|
+
outcome: "ok" | "missing" | "unknown";
|
|
34
|
+
/** HTTP status when the server answered; absent on a rejected request. */
|
|
35
|
+
status?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function resolveHashProject(id: string): Promise<ProjectHashVerdict> {
|
|
32
39
|
try {
|
|
33
40
|
const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
|
34
|
-
if (res.ok) return "ok";
|
|
35
|
-
return res.status === 404 ? "missing" : "unknown";
|
|
41
|
+
if (res.ok) return { outcome: "ok", status: res.status };
|
|
42
|
+
return { outcome: res.status === 404 ? "missing" : "unknown", status: res.status };
|
|
36
43
|
} catch {
|
|
37
|
-
return "unknown";
|
|
44
|
+
return { outcome: "unknown" };
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What the hash validation decided. Without this, a tab that kept a hash
|
|
50
|
+
* because the check itself failed ("unknown") is indistinguishable from one
|
|
51
|
+
* that never ran the check — the two have different fixes and, until this
|
|
52
|
+
* event, no way to tell them apart in production.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Rewrite the project hash WITHOUT pushing a history entry.
|
|
56
|
+
*
|
|
57
|
+
* `location.hash =` pushes. That leaves the dead id as the previous entry, so
|
|
58
|
+
* Back returns to it — and now that hashchange validates, Back would re-run the
|
|
59
|
+
* whole fallback and push again, leaving a trail of dead ids the user cannot
|
|
60
|
+
* escape with the Back button.
|
|
61
|
+
*/
|
|
62
|
+
function replaceProjectHash(projectId: string): void {
|
|
63
|
+
window.history.replaceState(null, "", buildProjectHash(projectId));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The first project the server offers, for a hash that resolves to nothing.
|
|
68
|
+
*
|
|
69
|
+
* Fetched here rather than cached from mount: the mount fetch is a snapshot,
|
|
70
|
+
* and the scenario this exists for is exactly the one where the project list
|
|
71
|
+
* changed underneath a long-lived tab.
|
|
72
|
+
*/
|
|
73
|
+
async function firstProjectId(): Promise<string | null> {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch("/api/projects");
|
|
76
|
+
const data = (await res.json()) as { projects?: Array<{ id?: string }> };
|
|
77
|
+
return data.projects?.[0]?.id ?? null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reportHashVerdict(stage: "mount" | "hashchange", verdict: ProjectHashVerdict): void {
|
|
84
|
+
trackStudioEvent("project_hash_validated", {
|
|
85
|
+
stage,
|
|
86
|
+
outcome: verdict.outcome,
|
|
87
|
+
...(verdict.status === undefined ? {} : { status: verdict.status }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
41
91
|
export function useServerConnection(): ServerConnectionState {
|
|
42
92
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
43
93
|
const [resolving, setResolving] = useState(true);
|
|
@@ -69,11 +119,12 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
69
119
|
// Telemetry after the read-reason split: 120 http_error/404 reads
|
|
70
120
|
// across 5 users in 24h, ~24 each, never recovering.
|
|
71
121
|
if (hashProjectId) {
|
|
72
|
-
const
|
|
122
|
+
const verdict = await resolveHashProject(hashProjectId);
|
|
73
123
|
if (cancelled) return;
|
|
124
|
+
reportHashVerdict("mount", verdict);
|
|
74
125
|
// "unknown" keeps the old behaviour: a transient failure must not
|
|
75
126
|
// rewrite the user's hash out from under a valid project.
|
|
76
|
-
if (
|
|
127
|
+
if (verdict.outcome !== "missing") {
|
|
77
128
|
setProjectId(hashProjectId);
|
|
78
129
|
setWaitingForServer(false);
|
|
79
130
|
return;
|
|
@@ -83,7 +134,7 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
83
134
|
if (first) {
|
|
84
135
|
setProjectId(first.id);
|
|
85
136
|
setWaitingForServer(false);
|
|
86
|
-
|
|
137
|
+
replaceProjectHash(first.id);
|
|
87
138
|
} else {
|
|
88
139
|
scheduleRetry();
|
|
89
140
|
}
|
|
@@ -107,7 +158,29 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
107
158
|
useEffect(() => {
|
|
108
159
|
const onHashChange = () => {
|
|
109
160
|
const next = parseProjectIdFromHash(window.location.hash);
|
|
110
|
-
if (next
|
|
161
|
+
if (!next || next === projectId) return;
|
|
162
|
+
// Validated, not adopted. The mount path has checked its hash since
|
|
163
|
+
// v0.8.52; this path took any id the URL offered, so changing projects
|
|
164
|
+
// re-armed the same dead-tab failure the mount check exists to prevent.
|
|
165
|
+
void (async () => {
|
|
166
|
+
const verdict = await resolveHashProject(next);
|
|
167
|
+
// Ground truth, not a captured flag. This effect re-registers on every
|
|
168
|
+
// projectId change, so a generation counter in this closure would reset
|
|
169
|
+
// and an in-flight verdict from the previous registration would read as
|
|
170
|
+
// current — adopting a project the user has already navigated away from.
|
|
171
|
+
// The hash itself cannot go stale.
|
|
172
|
+
if (parseProjectIdFromHash(window.location.hash) !== next) return;
|
|
173
|
+
reportHashVerdict("hashchange", verdict);
|
|
174
|
+
if (verdict.outcome !== "missing") {
|
|
175
|
+
setProjectId(next);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const fallbackId = await firstProjectId();
|
|
179
|
+
if (parseProjectIdFromHash(window.location.hash) !== next) return;
|
|
180
|
+
if (!fallbackId) return;
|
|
181
|
+
setProjectId(fallbackId);
|
|
182
|
+
replaceProjectHash(fallbackId);
|
|
183
|
+
})();
|
|
111
184
|
};
|
|
112
185
|
window.addEventListener("hashchange", onHashChange);
|
|
113
186
|
return () => window.removeEventListener("hashchange", onHashChange);
|
package/src/index.ts
CHANGED
|
@@ -46,16 +46,36 @@ export {
|
|
|
46
46
|
VideoThumbnail,
|
|
47
47
|
CompositionThumbnail,
|
|
48
48
|
useTimelinePlayer,
|
|
49
|
+
usePlayerHandle,
|
|
49
50
|
resolveIframe,
|
|
50
51
|
usePlayerStore,
|
|
51
52
|
liveTime,
|
|
52
53
|
formatTime,
|
|
53
54
|
} from "./player";
|
|
54
|
-
export type {
|
|
55
|
+
export type {
|
|
56
|
+
PlayerHandle,
|
|
57
|
+
PlayerHandleElement,
|
|
58
|
+
PlayerHandleListener,
|
|
59
|
+
PlayerHandleTimeListener,
|
|
60
|
+
TimelineElement,
|
|
61
|
+
TimelineTimeRange,
|
|
62
|
+
} from "./player";
|
|
55
63
|
|
|
56
64
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
57
65
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
58
66
|
export type { PreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
67
|
+
export {
|
|
68
|
+
PreviewOverlayProvider,
|
|
69
|
+
usePreviewOverlayContext,
|
|
70
|
+
} from "./components/editor/PreviewOverlayProvider";
|
|
71
|
+
export type {
|
|
72
|
+
PreviewOverlayProviderProps,
|
|
73
|
+
PreviewSnapPreferences,
|
|
74
|
+
} from "./components/editor/PreviewOverlayProvider";
|
|
75
|
+
export { PreviewGuides } from "./components/editor/PreviewGuides";
|
|
76
|
+
export { GridOverlay } from "./components/editor/GridOverlay";
|
|
77
|
+
export { SnapToolbar } from "./components/editor/SnapToolbar";
|
|
78
|
+
export { usePreviewGuidesStore } from "./components/editor/previewGuidesStore";
|
|
59
79
|
|
|
60
80
|
// Editor
|
|
61
81
|
export { SourceEditor } from "./components/editor/SourceEditor";
|
|
@@ -95,9 +95,9 @@ export function LayerDisclosureRow({
|
|
|
95
95
|
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
|
96
96
|
◇
|
|
97
97
|
</span>
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
<span title={name} className="min-w-0 flex-1 truncate font-medium leading-tight">
|
|
99
|
+
{name}
|
|
100
|
+
</span>
|
|
101
101
|
<TrackClipCount clipCount={clipCount} />
|
|
102
102
|
{children}
|
|
103
103
|
{/* Anchored right, on every header that has one: the lane toggle is the
|