@hyperframes/studio 0.8.48 → 0.8.50
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-CxxDxHTW.js +464 -0
- package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6209 -5924
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +20 -51
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +10 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser stub for the Node-only `canvas` package.
|
|
3
|
+
*
|
|
4
|
+
* linkedom's `HTMLCanvasElement` constructor calls `createCanvas(300, 150)`
|
|
5
|
+
* (300x150 being the HTML canvas default size). linkedom guards that import in
|
|
6
|
+
* a try/catch: the try requires the native `canvas` package, and the catch
|
|
7
|
+
* falls back to linkedom's own bundled canvas shim.
|
|
8
|
+
*
|
|
9
|
+
* That guard only works with a *runtime* require. Vite resolves the specifier
|
|
10
|
+
* statically, so the bundled form is a plain assignment that cannot throw, the
|
|
11
|
+
* catch never runs, and `createCanvas` lands `undefined`. Parsing any `<canvas>`
|
|
12
|
+
* tag then throws inside `openComposition`, Studio loses its SDK session for the
|
|
13
|
+
* whole composition, and every edit silently falls back to the server path — the
|
|
14
|
+
* resolver shadow does not run either, so it was invisible until
|
|
15
|
+
* `studio:sdk_session_unavailable` shipped in v0.8.47.
|
|
16
|
+
*
|
|
17
|
+
* Production telemetry over the 72h after the flip: 1065 of 1544 session-open
|
|
18
|
+
* failures were this crash, across a large share of 197 users. Canvas is common
|
|
19
|
+
* in compositions (Three.js, shaders, particle effects, charts).
|
|
20
|
+
*
|
|
21
|
+
* Aliasing `canvas` to this file makes the browser build resolve to a
|
|
22
|
+
* measurement-only canvas, which is all linkedom's static parse needs — nothing
|
|
23
|
+
* in the SDK rasterizes through it. It mirrors linkedom's own
|
|
24
|
+
* `commonjs/canvas-shim.cjs`, reimplemented here because linkedom's `exports`
|
|
25
|
+
* map does not expose that file.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
class BrowserCanvasStub {
|
|
29
|
+
constructor(width, height) {
|
|
30
|
+
this.width = width;
|
|
31
|
+
this.height = height;
|
|
32
|
+
}
|
|
33
|
+
/** linkedom only ever needs the element to exist; nothing draws through it. */
|
|
34
|
+
getContext() {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
toDataURL() {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createCanvas(width, height) {
|
|
43
|
+
return new BrowserCanvasStub(width, height);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// linkedom consumes this as a DEFAULT import — `import Canvas from
|
|
47
|
+
// '../../commonjs/canvas.cjs'` then `const {createCanvas} = Canvas` — so the
|
|
48
|
+
// default export is the load-bearing one; the named export above is interop
|
|
49
|
+
// belt-and-braces.
|
|
50
|
+
export default { createCanvas };
|
|
@@ -63,5 +63,9 @@ describe("addBlockToProject", () => {
|
|
|
63
63
|
expect(source).toContain('data-start="2.5"');
|
|
64
64
|
expect(source).toContain('data-duration="4.25"');
|
|
65
65
|
expect(source).toContain('data-track-index="3"');
|
|
66
|
+
// hostKey addresses the new element the same way the timeline selects
|
|
67
|
+
// clips (sourceFile#domId), so the caller can select and reveal it.
|
|
68
|
+
expect(result?.hostKey).toBe("compositions/scene.html#camcorder-hud");
|
|
69
|
+
expect(source).toContain('id="camcorder-hud"');
|
|
66
70
|
});
|
|
67
71
|
});
|
|
@@ -11,6 +11,7 @@ import { formatTimelineAttributeNumber } from "../player/components/timelineEdit
|
|
|
11
11
|
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
12
12
|
import type { EditHistoryKind } from "./editHistory";
|
|
13
13
|
import { extendRootDurationInSource } from "./rootDuration";
|
|
14
|
+
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
14
15
|
|
|
15
16
|
function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
|
|
16
17
|
try {
|
|
@@ -174,7 +175,7 @@ function buildSubCompositionHtml({
|
|
|
174
175
|
|
|
175
176
|
export async function addBlockToProject(
|
|
176
177
|
opts: AddBlockOptions,
|
|
177
|
-
): Promise<{ block: RegistryItem; compositionPath: string } | null> {
|
|
178
|
+
): Promise<{ block: RegistryItem; compositionPath: string; hostKey: string } | null> {
|
|
178
179
|
const {
|
|
179
180
|
projectId,
|
|
180
181
|
blockName,
|
|
@@ -213,8 +214,9 @@ export async function addBlockToProject(
|
|
|
213
214
|
currentTime: opts.currentTime ?? 0,
|
|
214
215
|
});
|
|
215
216
|
const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
|
|
217
|
+
const hostId = buildUniqueCompositionId(block.name, collectHtmlIds(originalContent));
|
|
216
218
|
const subComposition = buildSubCompositionHtml({
|
|
217
|
-
id:
|
|
219
|
+
id: hostId,
|
|
218
220
|
compositionFile,
|
|
219
221
|
start,
|
|
220
222
|
duration,
|
|
@@ -242,7 +244,11 @@ export async function addBlockToProject(
|
|
|
242
244
|
await refreshFileTree();
|
|
243
245
|
reloadPreview();
|
|
244
246
|
|
|
245
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
block,
|
|
249
|
+
compositionPath: compositionFile,
|
|
250
|
+
hostKey: deriveTimelineStoreKeyForDomId(hostId, targetPath),
|
|
251
|
+
};
|
|
246
252
|
} catch (error) {
|
|
247
253
|
const message = error instanceof Error ? error.message : "Failed to add block";
|
|
248
254
|
showToast(message);
|
|
@@ -33,10 +33,41 @@ describe("deduplicateIds", () => {
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
describe("serializeClipboardPayload / deserializeClipboardPayload", () => {
|
|
36
|
-
it("round-trips a timeline
|
|
36
|
+
it("round-trips a single-clip timeline payload", () => {
|
|
37
37
|
const payload: ClipboardPayload = {
|
|
38
38
|
kind: "timeline-clip",
|
|
39
|
-
|
|
39
|
+
clips: [
|
|
40
|
+
{
|
|
41
|
+
html: '<img id="photo" src="a.png" data-start="1" data-duration="3" />',
|
|
42
|
+
start: 1,
|
|
43
|
+
duration: 3,
|
|
44
|
+
track: 0,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
sourceFile: "index.html",
|
|
48
|
+
};
|
|
49
|
+
const json = serializeClipboardPayload(payload);
|
|
50
|
+
const parsed = deserializeClipboardPayload(json);
|
|
51
|
+
expect(parsed).toEqual(payload);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("round-trips a group timeline-clip payload", () => {
|
|
55
|
+
const payload: ClipboardPayload = {
|
|
56
|
+
kind: "timeline-clip",
|
|
57
|
+
clips: [
|
|
58
|
+
{
|
|
59
|
+
html: '<img id="a" data-start="1" data-duration="0.5" />',
|
|
60
|
+
start: 1,
|
|
61
|
+
duration: 0.5,
|
|
62
|
+
track: 0,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
html: '<img id="b" data-start="2" data-duration="0.5" />',
|
|
66
|
+
start: 2,
|
|
67
|
+
duration: 0.5,
|
|
68
|
+
track: 1,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
40
71
|
sourceFile: "index.html",
|
|
41
72
|
};
|
|
42
73
|
const json = serializeClipboardPayload(payload);
|
|
@@ -59,4 +90,13 @@ describe("serializeClipboardPayload / deserializeClipboardPayload", () => {
|
|
|
59
90
|
expect(deserializeClipboardPayload("not json")).toBeNull();
|
|
60
91
|
expect(deserializeClipboardPayload('{"kind":"unknown"}')).toBeNull();
|
|
61
92
|
});
|
|
93
|
+
|
|
94
|
+
it("returns null for a timeline-clip payload with no clips", () => {
|
|
95
|
+
const json = serializeClipboardPayload({
|
|
96
|
+
kind: "timeline-clip",
|
|
97
|
+
clips: [],
|
|
98
|
+
sourceFile: "index.html",
|
|
99
|
+
});
|
|
100
|
+
expect(deserializeClipboardPayload(json)).toBeNull();
|
|
101
|
+
});
|
|
62
102
|
});
|
|
@@ -2,35 +2,35 @@ import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
|
|
2
2
|
|
|
3
3
|
const CLIPBOARD_MARKER = "hyperframes-clipboard:v1";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/** One clip's copied markup plus the original placement needed to preserve
|
|
6
|
+
* relative offsets and tracks across a group paste. */
|
|
7
|
+
export interface TimelineClipboardClip {
|
|
7
8
|
html: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
start: number;
|
|
10
|
+
duration: number;
|
|
11
|
+
track: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
export type ClipboardPayload =
|
|
15
|
+
| { kind: "timeline-clip"; clips: TimelineClipboardClip[]; sourceFile: string }
|
|
16
|
+
| {
|
|
17
|
+
kind: "dom-element";
|
|
18
|
+
html: string;
|
|
19
|
+
sourceFile: string;
|
|
20
|
+
originSelector?: string;
|
|
21
|
+
originSelectorIndex?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type SerializedPayload = { _marker: string } & ClipboardPayload;
|
|
21
25
|
|
|
22
26
|
export function serializeClipboardPayload(payload: ClipboardPayload): string {
|
|
23
|
-
const data: SerializedPayload = {
|
|
24
|
-
_marker: CLIPBOARD_MARKER,
|
|
25
|
-
kind: payload.kind,
|
|
26
|
-
html: payload.html,
|
|
27
|
-
sourceFile: payload.sourceFile,
|
|
28
|
-
originSelector: payload.originSelector,
|
|
29
|
-
originSelectorIndex: payload.originSelectorIndex,
|
|
30
|
-
};
|
|
27
|
+
const data: SerializedPayload = { _marker: CLIPBOARD_MARKER, ...payload };
|
|
31
28
|
return JSON.stringify(data);
|
|
32
29
|
}
|
|
33
30
|
|
|
31
|
+
// Each branch validates one wire shape at the trust boundary; splitting further
|
|
32
|
+
// would fragment one parse into partial validators with no independent reuse.
|
|
33
|
+
// fallow-ignore-next-line complexity
|
|
34
34
|
export function deserializeClipboardPayload(json: string): ClipboardPayload | null {
|
|
35
35
|
let parsed: unknown;
|
|
36
36
|
try {
|
|
@@ -41,16 +41,35 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
|
|
|
41
41
|
if (!parsed || typeof parsed !== "object") return null;
|
|
42
42
|
const obj = parsed as Record<string, unknown>;
|
|
43
43
|
if (obj._marker !== CLIPBOARD_MARKER) return null;
|
|
44
|
-
if (
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
if (typeof obj.sourceFile !== "string") return null;
|
|
45
|
+
if (obj.kind === "timeline-clip") {
|
|
46
|
+
if (!Array.isArray(obj.clips)) return null;
|
|
47
|
+
const clips = obj.clips.filter((c): c is TimelineClipboardClip => {
|
|
48
|
+
const clip = c as Partial<TimelineClipboardClip> | null;
|
|
49
|
+
return (
|
|
50
|
+
!!clip &&
|
|
51
|
+
typeof clip === "object" &&
|
|
52
|
+
typeof clip.html === "string" &&
|
|
53
|
+
typeof clip.start === "number" &&
|
|
54
|
+
typeof clip.duration === "number" &&
|
|
55
|
+
typeof clip.track === "number"
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
if (clips.length === 0) return null;
|
|
59
|
+
return { kind: "timeline-clip", clips, sourceFile: obj.sourceFile };
|
|
60
|
+
}
|
|
61
|
+
if (obj.kind === "dom-element") {
|
|
62
|
+
if (typeof obj.html !== "string") return null;
|
|
63
|
+
return {
|
|
64
|
+
kind: "dom-element",
|
|
65
|
+
html: obj.html,
|
|
66
|
+
sourceFile: obj.sourceFile,
|
|
67
|
+
originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
|
|
68
|
+
originSelectorIndex:
|
|
69
|
+
typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
/**
|
|
@@ -59,6 +78,7 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
|
|
|
59
78
|
* the composition root if the selector doesn't match — so paste never silently
|
|
60
79
|
* drops the content.
|
|
61
80
|
*/
|
|
81
|
+
// fallow-ignore-next-line complexity
|
|
62
82
|
export function insertAsSibling(
|
|
63
83
|
source: string,
|
|
64
84
|
newHtml: string,
|
|
@@ -110,6 +130,7 @@ export function insertAsSibling(
|
|
|
110
130
|
return source + newHtml;
|
|
111
131
|
}
|
|
112
132
|
|
|
133
|
+
// fallow-ignore-next-line complexity
|
|
113
134
|
function findClosingTagPosition(html: string, openTagStart: number): number {
|
|
114
135
|
// Find the end of the opening tag
|
|
115
136
|
const openTagEnd = html.indexOf(">", openTagStart);
|
|
@@ -156,9 +177,13 @@ function findClosingTagPosition(html: string, openTagStart: number): number {
|
|
|
156
177
|
return -1;
|
|
157
178
|
}
|
|
158
179
|
|
|
180
|
+
/** An `id="..."` attribute, not `data-id="..."` or similar — only matches
|
|
181
|
+
* when preceded by whitespace, the way every generated attribute is. */
|
|
182
|
+
export const ID_ATTR_RE = /(?<=\s)id="([^"]+)"/;
|
|
183
|
+
|
|
159
184
|
export function deduplicateIds(html: string, existingIds: string[]): string {
|
|
160
185
|
const existingSet = new Set(existingIds);
|
|
161
|
-
return html.replace(
|
|
186
|
+
return html.replace(new RegExp(ID_ATTR_RE.source, "g"), (full, id: string) => {
|
|
162
187
|
if (!existingSet.has(id)) return full;
|
|
163
188
|
let counter = 2;
|
|
164
189
|
while (existingSet.has(`${id}-${counter}`)) counter++;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
ACTION_SAFE_PERCENT,
|
|
6
|
+
SAFE_BOX_PERCENTS,
|
|
7
|
+
TITLE_SAFE_PERCENT,
|
|
8
|
+
safeBoxInsetPercent,
|
|
9
|
+
} from "./previewSafeMargins";
|
|
10
|
+
|
|
11
|
+
const SKILL_PATH = join(import.meta.dirname, "../../../../skills/hyperframes-studio/SKILL.md");
|
|
12
|
+
|
|
13
|
+
describe("safe boxes", () => {
|
|
14
|
+
it("are the 90% and 80% boxes, inset 5% and 10% from every edge", () => {
|
|
15
|
+
expect(SAFE_BOX_PERCENTS).toEqual([90, 80]);
|
|
16
|
+
expect(SAFE_BOX_PERCENTS.map(safeBoxInsetPercent)).toEqual([5, 10]);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Skipped until the hyperframes-studio skill exists on this branch.
|
|
21
|
+
describe.runIf(existsSync(SKILL_PATH))("hyperframes-studio skill matches the constants", () => {
|
|
22
|
+
it("has a safe-margins line naming both percentages", () => {
|
|
23
|
+
const skill = readFileSync(SKILL_PATH, "utf8");
|
|
24
|
+
const line = skill
|
|
25
|
+
.split("\n")
|
|
26
|
+
.find(
|
|
27
|
+
(l) =>
|
|
28
|
+
/\bsafe margins?\b/i.test(l) &&
|
|
29
|
+
l.includes(`${ACTION_SAFE_PERCENT}%`) &&
|
|
30
|
+
l.includes(`${TITLE_SAFE_PERCENT}%`),
|
|
31
|
+
);
|
|
32
|
+
expect(line).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Adobe Premiere default: action-safe and title-safe margins are 10% and 20% total (5% and 10% per edge).
|
|
2
|
+
export const ACTION_SAFE_PERCENT = 90;
|
|
3
|
+
export const TITLE_SAFE_PERCENT = 80;
|
|
4
|
+
|
|
5
|
+
export const SAFE_BOX_PERCENTS = [ACTION_SAFE_PERCENT, TITLE_SAFE_PERCENT] as const;
|
|
6
|
+
|
|
7
|
+
/** Inset from each frame edge, as a percent of the frame. */
|
|
8
|
+
export function safeBoxInsetPercent(boxPercent: number): number {
|
|
9
|
+
return (100 - boxPercent) / 2;
|
|
10
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MutableRefObject } from "react";
|
|
2
|
+
|
|
3
|
+
export interface RetryBudgetState<K> {
|
|
4
|
+
id: K;
|
|
5
|
+
count: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A target still resolving (a just-dropped/pasted/duplicated element, or a
|
|
9
|
+
// preview still reloading) heals within a few retries; one that never will
|
|
10
|
+
// would otherwise retry forever. A new id resets the count.
|
|
11
|
+
export function recordRetryAttempt<K>(
|
|
12
|
+
ref: MutableRefObject<RetryBudgetState<K>>,
|
|
13
|
+
id: K,
|
|
14
|
+
maxRetries: number,
|
|
15
|
+
): boolean {
|
|
16
|
+
const previous = ref.current;
|
|
17
|
+
const count = previous.id === id ? previous.count + 1 : 1;
|
|
18
|
+
ref.current = { id, count };
|
|
19
|
+
return count <= maxRetries;
|
|
20
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* linkedom's HTMLCanvasElement constructor calls `createCanvas(300, 150)` from
|
|
9
|
+
* the Node-only `canvas` package. linkedom guards that import in a try/catch —
|
|
10
|
+
* try the native package, fall back to its own bundled shim — but a bundler
|
|
11
|
+
* resolves the require statically, so the catch never fires in the browser
|
|
12
|
+
* build and `createCanvas` lands undefined. Parsing any `<canvas>` tag then
|
|
13
|
+
* throws, `openComposition` rejects, and Studio silently loses its SDK session
|
|
14
|
+
* for the whole composition (no cutover, and the resolver shadow does not run
|
|
15
|
+
* either).
|
|
16
|
+
*
|
|
17
|
+
* Production telemetry, 72h after the v0.8.47 flip: 1065 of 1544
|
|
18
|
+
* `studio:sdk_session_unavailable` events were this crash, spread across a
|
|
19
|
+
* large share of 197 users. Canvas is common in compositions — Three.js,
|
|
20
|
+
* shaders, particle effects, charts.
|
|
21
|
+
*
|
|
22
|
+
* The fix is a `resolve.alias` in the studio's vite config pointing `canvas` at
|
|
23
|
+
* linkedom's own shim. This test pins the behaviour the alias has to preserve.
|
|
24
|
+
*/
|
|
25
|
+
const CANVAS_COMPOSITION = `<!doctype html>
|
|
26
|
+
<html>
|
|
27
|
+
<body data-composition-id="main" data-width="1920" data-height="1080">
|
|
28
|
+
<div class="clip" data-start="0" data-duration="3">
|
|
29
|
+
<canvas id="scene" width="800" height="600"></canvas>
|
|
30
|
+
</div>
|
|
31
|
+
</body>
|
|
32
|
+
</html>`;
|
|
33
|
+
|
|
34
|
+
describe("openComposition with a <canvas> element", () => {
|
|
35
|
+
it("opens a session instead of throwing on createCanvas", async () => {
|
|
36
|
+
const session = await openComposition(CANVAS_COMPOSITION, { history: false });
|
|
37
|
+
expect(session).toBeTruthy();
|
|
38
|
+
session.dispose();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("models the canvas element so edits can target it", async () => {
|
|
42
|
+
const session = await openComposition(CANVAS_COMPOSITION, { history: false });
|
|
43
|
+
// A composition whose elements cannot be modelled is a session the cutover
|
|
44
|
+
// path declines wholesale (sessionEmpty), which is the same silent bypass by
|
|
45
|
+
// a different route.
|
|
46
|
+
expect(session.getElements().length).toBeGreaterThan(0);
|
|
47
|
+
session.dispose();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("canvas browser-stub alias", () => {
|
|
52
|
+
// The two tests above pass with OR without the fix: vitest resolves
|
|
53
|
+
// `require('canvas')` at runtime, so linkedom's own try/catch fallback works
|
|
54
|
+
// here. Only the bundled build breaks. Nothing else in this suite can observe
|
|
55
|
+
// the bundler, so the alias itself is what has to be pinned — without this,
|
|
56
|
+
// deleting the alias reintroduces a crash that is invisible until it reaches
|
|
57
|
+
// production telemetry.
|
|
58
|
+
it("is configured, so the browser build never resolves the Node canvas package", () => {
|
|
59
|
+
const config = readFileSync(resolve(import.meta.dirname, "../../vite.config.ts"), "utf8");
|
|
60
|
+
expect(config).toMatch(
|
|
61
|
+
/\bcanvas:\s*resolve\(__dirname,\s*"src\/shims\/canvasBrowserStub\.js"\)/,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("exports the createCanvas shape linkedom destructures", async () => {
|
|
66
|
+
const stub = await import("../shims/canvasBrowserStub.js");
|
|
67
|
+
// linkedom takes the DEFAULT import and destructures createCanvas off it
|
|
68
|
+
// (`import Canvas from '...'; const {createCanvas} = Canvas`), so the
|
|
69
|
+
// default export is the one that actually has to carry the function.
|
|
70
|
+
expect(typeof stub.default.createCanvas).toBe("function");
|
|
71
|
+
expect(typeof stub.createCanvas).toBe("function");
|
|
72
|
+
const canvas = stub.default.createCanvas(300, 150);
|
|
73
|
+
expect(canvas.width).toBe(300);
|
|
74
|
+
expect(canvas.height).toBe(150);
|
|
75
|
+
expect(canvas.getContext()).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -41,6 +41,10 @@ describe("SDK family gate mapping", () => {
|
|
|
41
41
|
{} as never,
|
|
42
42
|
deps,
|
|
43
43
|
),
|
|
44
|
-
|
|
44
|
+
// The dom family is off, so the gate declines on the FLAG, not on the op
|
|
45
|
+
// batch (which is a perfectly eligible inline-style edit). Before the
|
|
46
|
+
// reason split this reported `ineligible_operation`, which read as "the
|
|
47
|
+
// SDK cannot express this edit" when the truth was "this family is off".
|
|
48
|
+
).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" });
|
|
45
49
|
});
|
|
46
50
|
});
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
|
7
7
|
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
|
|
8
8
|
import {
|
|
9
9
|
isResolverDisagreement,
|
|
10
|
+
sdkCutoverIneligibleReason,
|
|
10
11
|
shouldDeclineTextCutoverForTarget,
|
|
11
|
-
shouldUseSdkCutover,
|
|
12
12
|
} from "./sdkCutoverEligibility";
|
|
13
13
|
import {
|
|
14
14
|
asCutoverError,
|
|
@@ -81,11 +81,14 @@ export async function sdkCutoverPersist(
|
|
|
81
81
|
deps: CutoverDeps,
|
|
82
82
|
options?: CutoverOptions,
|
|
83
83
|
): Promise<CutoverResult> {
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Name WHICH eligibility check failed: on v0.8.47 this one string covered 26 of
|
|
85
|
+
// 30 dom declines. The two below aren't properties of the batch, so they differ.
|
|
86
|
+
if (!sdkFamilyEnabled("dom")) return declinedCutover("feature_disabled", "dom");
|
|
86
87
|
if (!sdkSession) return declinedCutover("session_unavailable", "dom");
|
|
87
88
|
const hfId = selection.hfId;
|
|
88
|
-
|
|
89
|
+
const ineligible = sdkCutoverIneligibleReason(hfId, ops);
|
|
90
|
+
// `!hfId` is already a reason; testing it again narrows the type for the rest.
|
|
91
|
+
if (ineligible || !hfId) return declinedCutover(ineligible ?? "target_unaddressable", "dom");
|
|
89
92
|
const target = sdkSession.getElement(hfId);
|
|
90
93
|
if (!target)
|
|
91
94
|
return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId));
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { PatchOperation } from "./sourcePatcher";
|
|
3
|
-
import { shouldUseSdkCutover } from "./sdkCutoverEligibility";
|
|
3
|
+
import { shouldUseSdkCutover, sdkCutoverIneligibleReason } from "./sdkCutoverEligibility";
|
|
4
|
+
|
|
5
|
+
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
6
|
+
type: "inline-style",
|
|
7
|
+
property,
|
|
8
|
+
value,
|
|
9
|
+
});
|
|
10
|
+
const textOp = (value: string): PatchOperation => ({
|
|
11
|
+
type: "text-content",
|
|
12
|
+
property: "text",
|
|
13
|
+
value,
|
|
14
|
+
});
|
|
15
|
+
const attrOp = (property: string, value: string): PatchOperation => ({
|
|
16
|
+
type: "attribute",
|
|
17
|
+
property,
|
|
18
|
+
value,
|
|
19
|
+
});
|
|
20
|
+
const htmlAttrOp = (property: string, value: string): PatchOperation => ({
|
|
21
|
+
type: "html-attribute",
|
|
22
|
+
property,
|
|
23
|
+
value,
|
|
24
|
+
});
|
|
4
25
|
|
|
5
26
|
const childStyleOp: PatchOperation = {
|
|
6
27
|
type: "inline-style",
|
|
@@ -15,3 +36,77 @@ describe("shouldUseSdkCutover child-scoped operations", () => {
|
|
|
15
36
|
expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false);
|
|
16
37
|
});
|
|
17
38
|
});
|
|
39
|
+
|
|
40
|
+
describe("sdkCutoverIneligibleReason", () => {
|
|
41
|
+
// `ineligible_operation` was one string covering four distinct causes, which
|
|
42
|
+
// made the post-flip decline telemetry unactionable: 26 of 30 declines on
|
|
43
|
+
// v0.8.47 landed there, with no way to separate a structural edit the SDK has
|
|
44
|
+
// no vocabulary for (expected, permanent) from a reserved-attribute or
|
|
45
|
+
// unsafe-attribute decline (narrower, and possibly fixable).
|
|
46
|
+
it("names an op type the SDK cannot express", () => {
|
|
47
|
+
expect(
|
|
48
|
+
sdkCutoverIneligibleReason("hf-abc", [
|
|
49
|
+
{ type: "split", property: "x", value: "1" } as unknown as PatchOperation,
|
|
50
|
+
]),
|
|
51
|
+
).toBe("unsupported_op_type");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("names a child-scoped op", () => {
|
|
55
|
+
expect(sdkCutoverIneligibleReason("hf-parent", [childStyleOp])).toBe("child_scoped_op");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("names a reserved attribute", () => {
|
|
59
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [attrOp("end", "2")])).toBe("reserved_attribute");
|
|
60
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(
|
|
61
|
+
"reserved_attribute",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("names an unsafe html attribute", () => {
|
|
66
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
|
|
67
|
+
"unsafe_html_attribute",
|
|
68
|
+
);
|
|
69
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("href", "javascript:alert(1)")])).toBe(
|
|
70
|
+
"unsafe_html_attribute",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("names an empty batch and an unaddressable target", () => {
|
|
75
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [])).toBe("no_ops");
|
|
76
|
+
expect(sdkCutoverIneligibleReason(null, [styleOp("color", "red")])).toBe(
|
|
77
|
+
"target_unaddressable",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns null when the batch is eligible", () => {
|
|
82
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [styleOp("color", "red")])).toBeNull();
|
|
83
|
+
expect(
|
|
84
|
+
sdkCutoverIneligibleReason("hf-abc", [
|
|
85
|
+
styleOp("color", "red"),
|
|
86
|
+
textOp("hello"),
|
|
87
|
+
attrOp("x", "1"),
|
|
88
|
+
htmlAttrOp("class", "foo"),
|
|
89
|
+
]),
|
|
90
|
+
).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The reason function must not drift from the predicate it explains: a batch
|
|
94
|
+
// the gate accepts must have no reason, and one it declines must have one.
|
|
95
|
+
it("agrees with shouldUseSdkCutover on every case", () => {
|
|
96
|
+
const cases: PatchOperation[][] = [
|
|
97
|
+
[styleOp("color", "red")],
|
|
98
|
+
[textOp("hello")],
|
|
99
|
+
[attrOp("x", "1")],
|
|
100
|
+
[htmlAttrOp("class", "foo")],
|
|
101
|
+
[attrOp("end", "2")],
|
|
102
|
+
[htmlAttrOp("onclick", "alert(1)")],
|
|
103
|
+
[childStyleOp],
|
|
104
|
+
[{ type: "split", property: "x", value: "1" } as unknown as PatchOperation],
|
|
105
|
+
[],
|
|
106
|
+
];
|
|
107
|
+
for (const ops of cases) {
|
|
108
|
+
const eligible = shouldUseSdkCutover(true, true, "hf-abc", ops);
|
|
109
|
+
expect(sdkCutoverIneligibleReason("hf-abc", ops) === null).toBe(eligible);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -123,21 +123,44 @@ export function isResolverDisagreement(session: Composition, hfId: string): bool
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Why a batch cannot take the SDK cutover path, or null when it can.
|
|
128
|
+
*
|
|
129
|
+
* `ineligible_operation` used to be a single decline reason covering all of
|
|
130
|
+
* these, which made the post-flip telemetry unactionable — a structural edit the
|
|
131
|
+
* SDK has no vocabulary for (expected and permanent) was indistinguishable from
|
|
132
|
+
* a reserved-attribute decline (narrow, and possibly worth fixing). The order
|
|
133
|
+
* mirrors `shouldUseSdkCutover`'s conjunction, so the first failing check names
|
|
134
|
+
* the reason.
|
|
135
|
+
*/
|
|
136
|
+
export type SdkCutoverIneligibleReason =
|
|
137
|
+
| "target_unaddressable"
|
|
138
|
+
| "no_ops"
|
|
139
|
+
| "unsupported_op_type"
|
|
140
|
+
| "child_scoped_op"
|
|
141
|
+
| "reserved_attribute"
|
|
142
|
+
| "unsafe_html_attribute";
|
|
143
|
+
|
|
144
|
+
export function sdkCutoverIneligibleReason(
|
|
145
|
+
hfId: string | null | undefined,
|
|
146
|
+
ops: PatchOperation[],
|
|
147
|
+
): SdkCutoverIneligibleReason | null {
|
|
148
|
+
if (!hfId) return "target_unaddressable";
|
|
149
|
+
if (ops.length === 0) return "no_ops";
|
|
150
|
+
if (!ops.every((o) => CUTOVER_OP_TYPES.has(o.type))) return "unsupported_op_type";
|
|
151
|
+
if (hasChildScopedOp(ops)) return "child_scoped_op";
|
|
152
|
+
if (ops.some(mapsToReservedAttr)) return "reserved_attribute";
|
|
153
|
+
if (hasUnsafeHtmlAttributeOp(ops)) return "unsafe_html_attribute";
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
126
157
|
export function shouldUseSdkCutover(
|
|
127
158
|
flagEnabled: boolean,
|
|
128
159
|
hasSession: boolean,
|
|
129
160
|
hfId: string | null | undefined,
|
|
130
161
|
ops: PatchOperation[],
|
|
131
162
|
): boolean {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
!!hfId &&
|
|
136
|
-
ops.length > 0 &&
|
|
137
|
-
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
|
|
138
|
-
// SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
|
|
139
|
-
!hasChildScopedOp(ops) &&
|
|
140
|
-
!ops.some(mapsToReservedAttr) &&
|
|
141
|
-
!hasUnsafeHtmlAttributeOp(ops)
|
|
142
|
-
);
|
|
163
|
+
// Derived from the reason function so the two can never disagree about which
|
|
164
|
+
// batches are eligible.
|
|
165
|
+
return flagEnabled && hasSession && sdkCutoverIneligibleReason(hfId, ops) === null;
|
|
143
166
|
}
|