@hyperframes/studio 0.8.60 → 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-Di7GyCOP.js → hyperframes-player-nmVkPQza.js} +1 -1
- package/dist/assets/{index-jAjhQTrB.js → index-2-HsQB2C.js} +1 -1
- package/dist/assets/{index-Dpq2lkOm.js → index-BK36b0XY.js} +160 -160
- package/dist/assets/{index-Dx_pt9m4.js → index-COG0K-CQ.js} +1 -1
- package/dist/assets/{index-CBKb_0TI.css → index-DS0kLbuM.css} +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3272 -2889
- 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/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 +113 -63
- package/src/player/components/TimelineClipFades.tsx +64 -31
- package/src/player/components/TimelineLanes.tsx +15 -8
- 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 +83 -27
- 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/timelineTheme.ts +6 -0
- 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 +4 -4
- package/src/styles/studio.css +84 -0
- package/src/styles/theme.css +10 -2
- package/src/utils/sdkCutover.test.ts +102 -1
- package/src/utils/sdkCutover.ts +16 -21
- package/src/utils/sdkEditTransaction.ts +50 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { TimelineLike } from "./playbackTypes";
|
|
2
|
+
|
|
3
|
+
const TRANSITION_LABEL_PREFIX = "hf:transition:";
|
|
4
|
+
|
|
5
|
+
type TimelineWithLabels = TimelineLike & {
|
|
6
|
+
labels?: Record<string, number>;
|
|
7
|
+
getLabels?: () => Record<string, number>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function transitionParts(label: string): [string, string] | null {
|
|
11
|
+
if (!label.startsWith(TRANSITION_LABEL_PREFIX)) return null;
|
|
12
|
+
const parts = label.slice(TRANSITION_LABEL_PREFIX.length).split(":");
|
|
13
|
+
if (parts.length < 3 || !parts[0] || !parts[1]) return null;
|
|
14
|
+
return [parts[0], parts[1]];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findTimedElement(doc: Document, markerId: string): Element | null {
|
|
18
|
+
for (const candidate of [markerId, `el-${markerId}`]) {
|
|
19
|
+
const byId = doc.getElementById(candidate);
|
|
20
|
+
if (byId?.hasAttribute("data-start")) return byId;
|
|
21
|
+
}
|
|
22
|
+
return (
|
|
23
|
+
Array.from(doc.querySelectorAll("[data-start]")).find(
|
|
24
|
+
(element) =>
|
|
25
|
+
element.getAttribute("data-hf-id") === markerId ||
|
|
26
|
+
element.getAttribute("data-composition-id") === markerId,
|
|
27
|
+
) ?? null
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function transitionLabelsForDocument(
|
|
32
|
+
doc: Document,
|
|
33
|
+
timelines: Readonly<Record<string, TimelineLike>> | undefined,
|
|
34
|
+
): ReadonlyMap<Element, string> {
|
|
35
|
+
const labels = new Map<Element, string>();
|
|
36
|
+
if (!timelines) return labels;
|
|
37
|
+
|
|
38
|
+
for (const timeline of Object.values(timelines) as TimelineWithLabels[]) {
|
|
39
|
+
const timelineLabels = timeline.getLabels?.() ?? timeline.labels ?? {};
|
|
40
|
+
for (const label of Object.keys(timelineLabels)) {
|
|
41
|
+
const pair = transitionParts(label);
|
|
42
|
+
if (!pair) continue;
|
|
43
|
+
const outgoing = findTimedElement(doc, pair[0]);
|
|
44
|
+
const incoming = findTimedElement(doc, pair[1]);
|
|
45
|
+
if (outgoing && incoming) {
|
|
46
|
+
labels.set(outgoing, label);
|
|
47
|
+
labels.set(incoming, label);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return labels;
|
|
52
|
+
}
|
|
@@ -49,6 +49,7 @@ export interface TimelineViewportBudgets {
|
|
|
49
49
|
|
|
50
50
|
const MEBIBYTE = 1024 * 1024;
|
|
51
51
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
52
|
+
export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // 4K timeline width / tallest 16:9 tile.
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* The sole default budget owner for timeline viewport and media virtualization.
|
|
@@ -66,7 +67,7 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
|
|
|
66
67
|
posterMaxPhysicalWidth: 240,
|
|
67
68
|
posterMaxPhysicalHeight: 135,
|
|
68
69
|
posterDprCap: 1.5,
|
|
69
|
-
richPreviewFrameCount:
|
|
70
|
+
richPreviewFrameCount: MAX_VISIBLE_THUMBNAIL_FRAMES,
|
|
70
71
|
concurrentVideoDecodes: 2,
|
|
71
72
|
concurrentMetadataJobs: 4,
|
|
72
73
|
concurrentCompositionFetches: 2,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"total":
|
|
2
|
+
"total": 297,
|
|
3
3
|
"files": {
|
|
4
4
|
"packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
|
|
5
5
|
"packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"packages/studio/src/components/sidebar/AudioRow.tsx": 2,
|
|
50
50
|
"packages/studio/src/components/ui/HyperframesMark.tsx": 4,
|
|
51
51
|
"packages/studio/src/hooks/useInlineTextEdit.ts": 1,
|
|
52
|
-
"packages/studio/src/player/components/AudioWaveform.tsx":
|
|
52
|
+
"packages/studio/src/player/components/AudioWaveform.tsx": 2,
|
|
53
53
|
"packages/studio/src/player/components/BeatStrip.tsx": 6,
|
|
54
54
|
"packages/studio/src/player/components/CompositionThumbnail.tsx": 2,
|
|
55
55
|
"packages/studio/src/player/components/Player.tsx": 2,
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"packages/studio/src/player/components/SpeedMenu.tsx": 2,
|
|
59
59
|
"packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
|
|
60
60
|
"packages/studio/src/telemetry/client.ts": 1,
|
|
61
|
-
"packages/studio/src/
|
|
62
|
-
"packages/studio/src/
|
|
61
|
+
"packages/studio/src/styles/studio.css": 48,
|
|
62
|
+
"packages/studio/src/webmcp/webmcpTestUtils.ts": 1
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/styles/studio.css
CHANGED
|
@@ -208,6 +208,72 @@ body {
|
|
|
208
208
|
var(--timeline-clip-shadow);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
.timeline-transition {
|
|
212
|
+
position: absolute;
|
|
213
|
+
width: 0;
|
|
214
|
+
height: 0;
|
|
215
|
+
pointer-events: none;
|
|
216
|
+
z-index: 30;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.timeline-transition__zone {
|
|
220
|
+
position: absolute;
|
|
221
|
+
left: calc(var(--timeline-transition-zone-width) / -2);
|
|
222
|
+
top: -21px;
|
|
223
|
+
width: var(--timeline-transition-zone-width);
|
|
224
|
+
height: 42px;
|
|
225
|
+
border-radius: 8px;
|
|
226
|
+
overflow: hidden;
|
|
227
|
+
background: var(--timeline-transition-zone);
|
|
228
|
+
filter: blur(2.5px);
|
|
229
|
+
opacity: 0.95;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.timeline-transition__image {
|
|
233
|
+
position: absolute;
|
|
234
|
+
inset: -4px;
|
|
235
|
+
background-position: center;
|
|
236
|
+
background-size: cover;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.timeline-transition__image--out {
|
|
240
|
+
mask-image: linear-gradient(90deg, black 0%, transparent 100%);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.timeline-transition__image--in {
|
|
244
|
+
mask-image: linear-gradient(90deg, transparent 0%, black 100%);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.timeline-transition__badge {
|
|
248
|
+
position: absolute;
|
|
249
|
+
left: -14px;
|
|
250
|
+
top: -14px;
|
|
251
|
+
display: grid;
|
|
252
|
+
width: 28px;
|
|
253
|
+
height: 28px;
|
|
254
|
+
place-items: center;
|
|
255
|
+
border: 1px solid color-mix(in srgb, var(--timeline-transition-badge-ink) 22%, transparent);
|
|
256
|
+
border-radius: 999px;
|
|
257
|
+
background: var(--timeline-transition-badge);
|
|
258
|
+
box-shadow: 0 2px 5px var(--timeline-playhead-shadow);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.timeline-transition__badge svg {
|
|
262
|
+
width: 17px;
|
|
263
|
+
height: 17px;
|
|
264
|
+
}
|
|
265
|
+
.timeline-transition__frame,
|
|
266
|
+
.timeline-transition__frame--out,
|
|
267
|
+
.timeline-transition__frame--in {
|
|
268
|
+
fill: none;
|
|
269
|
+
stroke: var(--timeline-transition-badge-ink);
|
|
270
|
+
stroke-width: 1.5;
|
|
271
|
+
}
|
|
272
|
+
.timeline-transition__intersection {
|
|
273
|
+
fill: var(--timeline-transition-badge-ink);
|
|
274
|
+
opacity: 0.7;
|
|
275
|
+
}
|
|
276
|
+
|
|
211
277
|
.timeline-clip::before {
|
|
212
278
|
content: "";
|
|
213
279
|
position: absolute;
|
|
@@ -245,6 +311,24 @@ body {
|
|
|
245
311
|
0 8px 24px var(--timeline-clip-shadow-dragging);
|
|
246
312
|
}
|
|
247
313
|
|
|
314
|
+
/* Mute greys the pill in place. Hot path: no transition. */
|
|
315
|
+
.timeline-clip.is-audio[data-clip-hidden="true"],
|
|
316
|
+
.timeline-clip.is-audio[data-audio-muted="true"] {
|
|
317
|
+
background-color: var(--timeline-clip-audio-muted-bg);
|
|
318
|
+
transition: none;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.timeline-audio-link {
|
|
322
|
+
position: absolute;
|
|
323
|
+
z-index: 4;
|
|
324
|
+
top: -9px;
|
|
325
|
+
left: 50%;
|
|
326
|
+
width: 1px;
|
|
327
|
+
height: 10px;
|
|
328
|
+
background: var(--timeline-audio-link);
|
|
329
|
+
pointer-events: none;
|
|
330
|
+
}
|
|
331
|
+
|
|
248
332
|
.timeline-clip.is-hovered {
|
|
249
333
|
background-color: var(--clip-bg-hover);
|
|
250
334
|
}
|
package/src/styles/theme.css
CHANGED
|
@@ -68,6 +68,8 @@
|
|
|
68
68
|
--timeline-clip-audio-border: rgba(167, 139, 250, 0.4);
|
|
69
69
|
--timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24);
|
|
70
70
|
--timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96);
|
|
71
|
+
--timeline-clip-audio-muted-bg: rgb(72, 74, 78);
|
|
72
|
+
--timeline-audio-link: #a0aaa4;
|
|
71
73
|
--timeline-clip-selection: rgba(255, 255, 255, 0.85);
|
|
72
74
|
--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
|
|
73
75
|
--timeline-clip-radius: 8px;
|
|
@@ -91,10 +93,12 @@
|
|
|
91
93
|
transparent 100%
|
|
92
94
|
);
|
|
93
95
|
--timeline-thumbnail-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
|
|
94
|
-
--timeline-waveform-bar-rgb:
|
|
96
|
+
--timeline-waveform-bar-rgb: 245, 237, 255;
|
|
97
|
+
--timeline-waveform-muted-rgb: 236, 238, 242;
|
|
98
|
+
--timeline-waveform-baseline-rgb: 226, 216, 238;
|
|
95
99
|
--timeline-waveform-error: repeating-linear-gradient(
|
|
96
100
|
90deg,
|
|
97
|
-
rgba(
|
|
101
|
+
rgba(245, 237, 255, 0.35) 0 2px,
|
|
98
102
|
transparent 2px 5px
|
|
99
103
|
);
|
|
100
104
|
--timeline-waveform-label-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
|
@@ -148,6 +152,9 @@
|
|
|
148
152
|
--timeline-clip-shadow-dragging: rgba(0, 0, 0, 0.4);
|
|
149
153
|
--timeline-playhead-glow: rgba(60, 230, 172, 0.14);
|
|
150
154
|
--timeline-playhead-shadow: rgba(0, 0, 0, 0.55);
|
|
155
|
+
--timeline-transition-zone: rgba(255, 255, 255, 0.2);
|
|
156
|
+
--timeline-transition-badge: rgba(237, 244, 239, 0.98);
|
|
157
|
+
--timeline-transition-badge-ink: rgba(36, 56, 45, 0.96);
|
|
151
158
|
|
|
152
159
|
/* -------------------------------------------------------------- geometry */
|
|
153
160
|
|
|
@@ -361,6 +368,7 @@
|
|
|
361
368
|
--timeline-audio-link: #626d66;
|
|
362
369
|
--timeline-waveform-bar-rgb: 20, 12, 32;
|
|
363
370
|
--timeline-waveform-muted-rgb: 24, 28, 26;
|
|
371
|
+
--timeline-waveform-baseline-rgb: 76, 62, 88;
|
|
364
372
|
--timeline-waveform-error: repeating-linear-gradient(
|
|
365
373
|
90deg,
|
|
366
374
|
rgba(20, 12, 32, 0.35) 0 2px,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
shouldUseSdkCutover,
|
|
4
4
|
sdkCutoverPersist,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { openComposition } from "@hyperframes/sdk";
|
|
15
15
|
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
|
16
16
|
import type { PatchOperation } from "./sourcePatcher";
|
|
17
|
+
import { StudioSaveNetworkError } from "./studioSaveDiagnostics";
|
|
17
18
|
|
|
18
19
|
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
|
19
20
|
STUDIO_SDK_CUTOVER_ENABLED: true,
|
|
@@ -1404,3 +1405,103 @@ gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
|
|
|
1404
1405
|
expect(written).toContain("gsap.timeline()");
|
|
1405
1406
|
});
|
|
1406
1407
|
});
|
|
1408
|
+
|
|
1409
|
+
describe("sdk_cutover_failed classification (error_kind)", () => {
|
|
1410
|
+
const makeDeps = (overrides: Partial<Parameters<typeof sdkCutoverPersist>[5]> = {}) => ({
|
|
1411
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
1412
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
1413
|
+
reloadPreview: vi.fn(),
|
|
1414
|
+
...candidateTestDeps(),
|
|
1415
|
+
...overrides,
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
const makeSession = () =>
|
|
1419
|
+
({
|
|
1420
|
+
getElement: vi.fn().mockReturnValue({ inlineStyles: {} }),
|
|
1421
|
+
dispatch: vi.fn(),
|
|
1422
|
+
serialize: vi
|
|
1423
|
+
.fn()
|
|
1424
|
+
.mockReturnValueOnce("<html>before</html>")
|
|
1425
|
+
.mockReturnValue("<html></html>"),
|
|
1426
|
+
batch: vi.fn((fn: () => void) => fn()),
|
|
1427
|
+
}) as unknown as Parameters<typeof sdkCutoverPersist>[4];
|
|
1428
|
+
|
|
1429
|
+
beforeEach(() => {
|
|
1430
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
// The production gate event this fix exists for: a bare `TypeError` from an
|
|
1434
|
+
// UNWRAPPED raw fetch (`readProjectFile`'s implementation in
|
|
1435
|
+
// useProjectFileWriter.ts, or writeProjectFile's own preflight — neither
|
|
1436
|
+
// wraps as StudioSaveNetworkError). Without the message-based fallback in
|
|
1437
|
+
// `cutoverErrorKind`, this files as `error_kind: "sdk"` and trips the
|
|
1438
|
+
// cutover-failure rollback gate on a network blip the SDK never owned.
|
|
1439
|
+
it("classifies a bare fetch TypeError from readProjectFile as network", async () => {
|
|
1440
|
+
const deps = makeDeps({
|
|
1441
|
+
readProjectFile: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")),
|
|
1442
|
+
});
|
|
1443
|
+
const session = makeSession();
|
|
1444
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
1445
|
+
|
|
1446
|
+
const result = await sdkCutoverPersist(
|
|
1447
|
+
sel,
|
|
1448
|
+
[styleOp("color", "red")],
|
|
1449
|
+
"before",
|
|
1450
|
+
"/path.html",
|
|
1451
|
+
session,
|
|
1452
|
+
deps,
|
|
1453
|
+
);
|
|
1454
|
+
|
|
1455
|
+
expect(result.status).toBe("failed");
|
|
1456
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
1457
|
+
"sdk_cutover_failed",
|
|
1458
|
+
expect.objectContaining({ family: "dom", error_kind: "network" }),
|
|
1459
|
+
);
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
it("classifies StudioSaveNetworkError from writeProjectFile as network", async () => {
|
|
1463
|
+
const deps = makeDeps({
|
|
1464
|
+
writeProjectFile: vi.fn().mockRejectedValue(new StudioSaveNetworkError("Failed to save")),
|
|
1465
|
+
});
|
|
1466
|
+
const session = makeSession();
|
|
1467
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
1468
|
+
|
|
1469
|
+
const result = await sdkCutoverPersist(
|
|
1470
|
+
sel,
|
|
1471
|
+
[styleOp("color", "red")],
|
|
1472
|
+
"before",
|
|
1473
|
+
"/path.html",
|
|
1474
|
+
session,
|
|
1475
|
+
deps,
|
|
1476
|
+
);
|
|
1477
|
+
|
|
1478
|
+
expect(result.status).toBe("failed");
|
|
1479
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
1480
|
+
"sdk_cutover_failed",
|
|
1481
|
+
expect.objectContaining({ family: "dom", error_kind: "network" }),
|
|
1482
|
+
);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it("classifies a plain error as an sdk defect, not network", async () => {
|
|
1486
|
+
const deps = makeDeps({
|
|
1487
|
+
writeProjectFile: vi.fn().mockRejectedValue(new Error("disk full")),
|
|
1488
|
+
});
|
|
1489
|
+
const session = makeSession();
|
|
1490
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
1491
|
+
|
|
1492
|
+
const result = await sdkCutoverPersist(
|
|
1493
|
+
sel,
|
|
1494
|
+
[styleOp("color", "red")],
|
|
1495
|
+
"before",
|
|
1496
|
+
"/path.html",
|
|
1497
|
+
session,
|
|
1498
|
+
deps,
|
|
1499
|
+
);
|
|
1500
|
+
|
|
1501
|
+
expect(result.status).toBe("failed");
|
|
1502
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
1503
|
+
"sdk_cutover_failed",
|
|
1504
|
+
expect.objectContaining({ family: "dom", error_kind: "sdk" }),
|
|
1505
|
+
);
|
|
1506
|
+
});
|
|
1507
|
+
});
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
shouldDeclineTextCutoverForTarget,
|
|
12
12
|
} from "./sdkCutoverEligibility";
|
|
13
13
|
import {
|
|
14
|
-
asCutoverError,
|
|
15
14
|
declinedCutover,
|
|
15
|
+
failedCutover,
|
|
16
16
|
persistSdkCandidateMutation,
|
|
17
17
|
type CutoverDeps,
|
|
18
18
|
type CutoverOptions,
|
|
@@ -45,12 +45,13 @@ function sdkFamilyEnabled(family: StudioSdkOperationFamily): boolean {
|
|
|
45
45
|
|
|
46
46
|
function trackCutoverResult(
|
|
47
47
|
result: CutoverResult,
|
|
48
|
+
family: StudioSdkOperationFamily,
|
|
48
49
|
context: { hfId?: string | null; opCount: number },
|
|
49
50
|
): void {
|
|
50
51
|
if (result.status === "committed") {
|
|
51
|
-
trackStudioEvent("sdk_cutover_success", context);
|
|
52
|
+
trackStudioEvent("sdk_cutover_success", { ...context, family });
|
|
52
53
|
} else if (result.status === "failed") {
|
|
53
|
-
|
|
54
|
+
failedCutover(result.error, family, context);
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -106,7 +107,7 @@ export async function sdkCutoverPersist(
|
|
|
106
107
|
},
|
|
107
108
|
options,
|
|
108
109
|
);
|
|
109
|
-
trackCutoverResult(result, { hfId, opCount: ops.length });
|
|
110
|
+
trackCutoverResult(result, "dom", { hfId, opCount: ops.length });
|
|
110
111
|
return result;
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -148,12 +149,10 @@ export async function sdkTimingPersist(
|
|
|
148
149
|
options,
|
|
149
150
|
serializedBefore,
|
|
150
151
|
);
|
|
151
|
-
trackCutoverResult(result, { hfId, opCount: 1 });
|
|
152
|
+
trackCutoverResult(result, "timing", { hfId, opCount: 1 });
|
|
152
153
|
return result;
|
|
153
154
|
} catch (error) {
|
|
154
|
-
|
|
155
|
-
trackStudioEvent("sdk_cutover_failed", { hfId, error: failed.error.message });
|
|
156
|
-
return failed;
|
|
155
|
+
return failedCutover(error, "timing", { hfId, opCount: 1 });
|
|
157
156
|
}
|
|
158
157
|
}
|
|
159
158
|
|
|
@@ -202,24 +201,22 @@ export async function sdkTimingBatchPersist(
|
|
|
202
201
|
serializedBefore,
|
|
203
202
|
);
|
|
204
203
|
if (result.status === "failed") {
|
|
205
|
-
|
|
204
|
+
return failedCutover(result.error, "timing", {
|
|
206
205
|
hfId: changes[0]?.hfId ?? null,
|
|
207
|
-
|
|
206
|
+
opCount: changes.length,
|
|
208
207
|
});
|
|
209
|
-
return result;
|
|
210
208
|
}
|
|
211
209
|
trackStudioEvent("sdk_cutover_success", {
|
|
212
210
|
hfId: changes[0]?.hfId ?? null,
|
|
213
211
|
opCount: changes.length,
|
|
212
|
+
family: "timing",
|
|
214
213
|
});
|
|
215
214
|
return result;
|
|
216
215
|
} catch (error) {
|
|
217
|
-
|
|
218
|
-
trackStudioEvent("sdk_cutover_failed", {
|
|
216
|
+
return failedCutover(error, "timing", {
|
|
219
217
|
hfId: changes[0]?.hfId ?? null,
|
|
220
|
-
|
|
218
|
+
opCount: changes.length,
|
|
221
219
|
});
|
|
222
|
-
return failed;
|
|
223
220
|
}
|
|
224
221
|
}
|
|
225
222
|
|
|
@@ -321,15 +318,13 @@ async function dispatchGsapOpAndPersist(
|
|
|
321
318
|
serializedBefore,
|
|
322
319
|
);
|
|
323
320
|
if (result.status === "committed") {
|
|
324
|
-
trackStudioEvent("sdk_cutover_success", { opCount: 1 });
|
|
321
|
+
trackStudioEvent("sdk_cutover_success", { opCount: 1, family });
|
|
325
322
|
} else if (result.status === "failed") {
|
|
326
|
-
|
|
323
|
+
return failedCutover(result.error, family, { opCount: 1 });
|
|
327
324
|
}
|
|
328
325
|
return result;
|
|
329
326
|
} catch (error) {
|
|
330
|
-
|
|
331
|
-
trackStudioEvent("sdk_cutover_failed", { error: failed.error.message });
|
|
332
|
-
return failed;
|
|
327
|
+
return failedCutover(error, family, { opCount: 1 });
|
|
333
328
|
}
|
|
334
329
|
}
|
|
335
330
|
|
|
@@ -595,6 +590,6 @@ export async function sdkDeletePersist(
|
|
|
595
590
|
(session) => session.removeElement(hfId),
|
|
596
591
|
{ label: "Delete element" },
|
|
597
592
|
);
|
|
598
|
-
trackCutoverResult(result, { hfId, opCount: 1 });
|
|
593
|
+
trackCutoverResult(result, "lifecycle", { hfId, opCount: 1 });
|
|
599
594
|
return result;
|
|
600
595
|
}
|
|
@@ -4,6 +4,11 @@ import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
|
|
|
4
4
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
5
5
|
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
|
|
6
6
|
import type { StudioSdkOperationFamily } from "./sdkCutoverPolicy";
|
|
7
|
+
import {
|
|
8
|
+
StudioFileConflictError,
|
|
9
|
+
StudioSaveHttpError,
|
|
10
|
+
StudioSaveNetworkError,
|
|
11
|
+
} from "./studioSaveDiagnostics";
|
|
7
12
|
|
|
8
13
|
export type CutoverResult =
|
|
9
14
|
| { status: "declined"; reason: string }
|
|
@@ -91,10 +96,54 @@ export function declinedCutover(
|
|
|
91
96
|
return { status: "declined", reason };
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
|
|
99
|
+
function asCutoverError(error: unknown): Error {
|
|
95
100
|
return error instanceof Error ? error : new Error(String(error));
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
// Browser rejection strings for a fetch that never produced a response (offline,
|
|
104
|
+
// the dev server gone, a CSP/PNA block, an extension rewriting fetch). See
|
|
105
|
+
// `useSdkSession.ts`'s `readProjectFileOptional` catch for the same list.
|
|
106
|
+
const FETCH_REJECTION_MESSAGE = /Failed to fetch|Load failed|NetworkError when attempting to fetch/;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Classify a cutover failure for the gate dashboard. `writeProjectFile`'s own
|
|
110
|
+
* fetch wraps a rejection as `StudioSaveNetworkError`, but two raw fetches in
|
|
111
|
+
* this same transaction — `readProjectFile` (via `captureOnDiskBefore`) and
|
|
112
|
+
* `writeProjectFile`'s own read-before-write preflight — throw an unwrapped
|
|
113
|
+
* `TypeError` that reaches here undecorated. Without the message fallback,
|
|
114
|
+
* those file as `"sdk"` and trip the cutover-failure rollback gate on a
|
|
115
|
+
* network blip the SDK never had a chance to own.
|
|
116
|
+
*/
|
|
117
|
+
function cutoverErrorKind(error: Error): "network" | "conflict" | "http" | "sdk" {
|
|
118
|
+
if (error instanceof StudioSaveNetworkError) return "network";
|
|
119
|
+
if (error instanceof StudioFileConflictError) return "conflict";
|
|
120
|
+
if (error instanceof StudioSaveHttpError) return "http";
|
|
121
|
+
if (error.name === "TypeError" && FETCH_REJECTION_MESSAGE.test(error.message)) return "network";
|
|
122
|
+
return "sdk";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Record a cutover that could not commit. Replaces ad hoc `trackStudioEvent("sdk_cutover_failed", ...)`
|
|
127
|
+
* calls so every gate event carries `family` and `error_kind` — without them the
|
|
128
|
+
* dashboard's rollback gate (any `sdk_cutover_failed` on a flipped version) cannot
|
|
129
|
+
* distinguish a real SDK defect from a rejected fetch it doesn't own.
|
|
130
|
+
*/
|
|
131
|
+
export function failedCutover(
|
|
132
|
+
error: unknown,
|
|
133
|
+
family: StudioSdkOperationFamily,
|
|
134
|
+
context: { hfId?: string | null; opCount?: number } = {},
|
|
135
|
+
): CutoverResult {
|
|
136
|
+
const err = asCutoverError(error);
|
|
137
|
+
trackStudioEvent("sdk_cutover_failed", {
|
|
138
|
+
...context,
|
|
139
|
+
family,
|
|
140
|
+
error_kind: cutoverErrorKind(err),
|
|
141
|
+
...(err instanceof StudioSaveHttpError ? { status: err.statusCode } : {}),
|
|
142
|
+
error: err.message,
|
|
143
|
+
});
|
|
144
|
+
return { status: "failed", error: err };
|
|
145
|
+
}
|
|
146
|
+
|
|
98
147
|
/** Only an explicit decline may enter the legacy mutation backend. */
|
|
99
148
|
export function cutoverCommittedOrThrow(result: CutoverResult): boolean {
|
|
100
149
|
if (result.status === "failed") throw result.error;
|