@hyperframes/studio 0.8.60 → 0.8.62
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-BMHU9-3w.js} +1 -1
- package/dist/assets/{index-Dx_pt9m4.js → index-Ce1SRT2i.js} +1 -1
- package/dist/assets/{index-Dpq2lkOm.js → index-DLYk_vu_.js} +186 -186
- package/dist/assets/{index-CBKb_0TI.css → index-DS0kLbuM.css} +1 -1
- package/dist/assets/{index-jAjhQTrB.js → index-Dg0Vjj_i.js} +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3308 -2895
- 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/hooks/useShadowPreviewReload.ts +41 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +53 -0
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { gsap } from "gsap";
|
|
3
4
|
import {
|
|
4
5
|
createTimelineElementFromManifestClip,
|
|
5
6
|
parseTimelineFromDOM,
|
|
@@ -20,6 +21,33 @@ function makeDoc(html: string): Document {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
24
|
+
it("bridges a real GSAP transition marker onto both named clips", () => {
|
|
25
|
+
document.body.innerHTML = `
|
|
26
|
+
<div data-composition-id="root">
|
|
27
|
+
<img data-hf-id="outgoing" data-start="0" data-duration="2.5" data-track-index="0" />
|
|
28
|
+
<img data-hf-id="incoming" data-start="2" data-duration="2.5" data-track-index="0" />
|
|
29
|
+
</div>
|
|
30
|
+
`;
|
|
31
|
+
const timeline = gsap.timeline({ paused: true });
|
|
32
|
+
timeline.addLabel("hf:transition:outgoing:incoming:crossfade", 2);
|
|
33
|
+
const runtimeWindow = window as Window & {
|
|
34
|
+
__timelines?: Record<string, typeof timeline>;
|
|
35
|
+
};
|
|
36
|
+
runtimeWindow.__timelines = { main: timeline };
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const elements = parseTimelineFromDOM(document, 5);
|
|
40
|
+
expect(elements.map((element) => element.transitionLabel)).toEqual([
|
|
41
|
+
"hf:transition:outgoing:incoming:crossfade",
|
|
42
|
+
"hf:transition:outgoing:incoming:crossfade",
|
|
43
|
+
]);
|
|
44
|
+
} finally {
|
|
45
|
+
delete runtimeWindow.__timelines;
|
|
46
|
+
timeline.kill();
|
|
47
|
+
document.body.innerHTML = "";
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
23
51
|
it("harvests hfId from a data-start element that has data-hf-id", () => {
|
|
24
52
|
const doc = makeDoc(`
|
|
25
53
|
<div data-composition-id="root">
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
-
import type { ClipManifestClip } from "./playbackTypes";
|
|
12
|
+
import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
|
|
13
13
|
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
14
14
|
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
|
15
15
|
import { groupInfoFor } from "./timelineGroupInfo";
|
|
16
|
+
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
|
|
16
17
|
import {
|
|
17
18
|
resolveMediaElement,
|
|
18
19
|
applyMediaMetadataFromElement,
|
|
@@ -75,6 +76,9 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
75
76
|
}): TimelineElement {
|
|
76
77
|
const { clip, fallbackIndex, doc } = params;
|
|
77
78
|
let hostEl = params.hostEl ?? null;
|
|
79
|
+
const transitionLabels = doc
|
|
80
|
+
? transitionLabelsForDocument(doc, (doc.defaultView as IframeWindow | null)?.__timelines)
|
|
81
|
+
: new Map<Element, string>();
|
|
78
82
|
const label = getTimelineElementDisplayLabel({
|
|
79
83
|
id: clip.id,
|
|
80
84
|
label: clip.label,
|
|
@@ -108,6 +112,10 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
108
112
|
const entry: TimelineElement = {
|
|
109
113
|
id: identity.id,
|
|
110
114
|
label,
|
|
115
|
+
transitionLabel:
|
|
116
|
+
(hostEl && transitionLabels.get(hostEl)) ||
|
|
117
|
+
hostEl?.getAttribute("data-transition-label") ||
|
|
118
|
+
undefined,
|
|
111
119
|
key: identity.key,
|
|
112
120
|
kind: clip.kind,
|
|
113
121
|
tag: resolveClipTag(clip),
|
|
@@ -134,6 +142,10 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
134
142
|
|
|
135
143
|
if (hostEl) {
|
|
136
144
|
applyMediaMetadataFromElement(entry, hostEl);
|
|
145
|
+
if (!entry.src) {
|
|
146
|
+
const rawSrc = hostEl.getAttribute("src");
|
|
147
|
+
if (rawSrc) entry.src = new URL(rawSrc, hostEl.baseURI).href;
|
|
148
|
+
}
|
|
137
149
|
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
|
|
138
150
|
const timelineRole = hostEl.getAttribute("data-timeline-role");
|
|
139
151
|
if (timelineRole) entry.timelineRole = timelineRole;
|
|
@@ -205,11 +217,19 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
205
217
|
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
206
218
|
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
207
219
|
*/
|
|
208
|
-
export function parseTimelineFromDOM(
|
|
220
|
+
export function parseTimelineFromDOM(
|
|
221
|
+
doc: Document,
|
|
222
|
+
rootDuration: number,
|
|
223
|
+
timelines?: Readonly<Record<string, TimelineLike>>,
|
|
224
|
+
): TimelineElement[] {
|
|
209
225
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
210
226
|
const nodes = doc.querySelectorAll("[data-start]");
|
|
211
227
|
const els: TimelineElement[] = [];
|
|
212
228
|
let trackCounter = 0;
|
|
229
|
+
const transitionLabels = transitionLabelsForDocument(
|
|
230
|
+
doc,
|
|
231
|
+
timelines ?? (doc.defaultView as IframeWindow | null)?.__timelines,
|
|
232
|
+
);
|
|
213
233
|
|
|
214
234
|
// fallow-ignore-next-line complexity
|
|
215
235
|
nodes.forEach((node) => {
|
|
@@ -252,6 +272,8 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
252
272
|
const entry: TimelineElement = {
|
|
253
273
|
id: identity.id,
|
|
254
274
|
label,
|
|
275
|
+
transitionLabel:
|
|
276
|
+
transitionLabels.get(el) ?? el.getAttribute("data-transition-label") ?? undefined,
|
|
255
277
|
key: identity.key,
|
|
256
278
|
kind:
|
|
257
279
|
compId && compId !== rootComp?.getAttribute("data-composition-id")
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
readTimelineElementZIndex,
|
|
23
23
|
} from "./timelineElementHelpers";
|
|
24
24
|
import { postRuntimeControlMessage } from "./runtimeProtocol";
|
|
25
|
+
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
|
|
25
26
|
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// Viewport / DOM normalisation
|
|
@@ -414,6 +415,7 @@ function buildMissingCompositionEntry(params: {
|
|
|
414
415
|
}): TimelineElement | null {
|
|
415
416
|
const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } =
|
|
416
417
|
params;
|
|
418
|
+
const transitionLabels = transitionLabelsForDocument(doc, iframeWin.__timelines);
|
|
417
419
|
const timing = readClipTiming(element, {
|
|
418
420
|
resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
|
|
419
421
|
});
|
|
@@ -447,6 +449,8 @@ function buildMissingCompositionEntry(params: {
|
|
|
447
449
|
const entry: TimelineElement = {
|
|
448
450
|
id: identity.id,
|
|
449
451
|
label,
|
|
452
|
+
transitionLabel:
|
|
453
|
+
transitionLabels.get(element) ?? element.getAttribute("data-transition-label") ?? undefined,
|
|
450
454
|
key: identity.key,
|
|
451
455
|
tag: element.tagName.toLowerCase(),
|
|
452
456
|
start: window.start,
|
|
@@ -459,6 +463,9 @@ function buildMissingCompositionEntry(params: {
|
|
|
459
463
|
selectorIndex,
|
|
460
464
|
sourceFile,
|
|
461
465
|
zIndex: readTimelineElementZIndex(element),
|
|
466
|
+
src: optionalNonEmpty(element.getAttribute("src"))
|
|
467
|
+
? new URL(element.getAttribute("src")!, element.baseURI).href
|
|
468
|
+
: undefined,
|
|
462
469
|
};
|
|
463
470
|
return attachCompositionSource(entry, element, compositionSrc);
|
|
464
471
|
}
|
|
@@ -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;
|