@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.
Files changed (124) hide show
  1. package/dist/assets/hyperframes-player-CxxDxHTW.js +464 -0
  2. package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +16 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6209 -5924
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +10 -8
  12. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  13. package/src/components/EditorShell.tsx +24 -0
  14. package/src/components/StudioOverlays.tsx +1 -5
  15. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  16. package/src/components/editor/PreviewGuides.tsx +125 -0
  17. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  18. package/src/components/editor/SnapToolbar.tsx +25 -1
  19. package/src/components/editor/domEditingAgentPrompt.ts +5 -0
  20. package/src/components/editor/previewGuidesStore.ts +26 -0
  21. package/src/components/nle/NLEPreview.test.ts +13 -0
  22. package/src/components/nle/NLEPreview.tsx +9 -4
  23. package/src/components/nle/PreviewPane.test.tsx +52 -0
  24. package/src/components/nle/PreviewPane.tsx +2 -0
  25. package/src/components/nle/TimelinePane.tsx +14 -0
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  28. package/src/hooks/appHotkeysDispatch.ts +11 -0
  29. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  30. package/src/hooks/timelineTrackVisibility.ts +8 -11
  31. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  32. package/src/hooks/useAppHotkeys.ts +3 -0
  33. package/src/hooks/useAskAgentModal.ts +2 -0
  34. package/src/hooks/useBlockHandlers.ts +7 -2
  35. package/src/hooks/useClipboard.test.ts +234 -0
  36. package/src/hooks/useClipboard.ts +264 -70
  37. package/src/hooks/useGroupCommits.test.ts +0 -1
  38. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  39. package/src/hooks/useRazorSplit.test.ts +0 -2
  40. package/src/hooks/useRazorSplit.test.tsx +0 -1
  41. package/src/hooks/useStudioContextValue.ts +5 -19
  42. package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
  43. package/src/hooks/useTimelineAssetDropOps.ts +4 -2
  44. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  45. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  46. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  47. package/src/player/components/ClipContextMenu.tsx +87 -7
  48. package/src/player/components/Timeline.tsx +14 -14
  49. package/src/player/components/TimelineCanvas.tsx +30 -0
  50. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  51. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  52. package/src/player/components/TimelineLanes.test.tsx +22 -1
  53. package/src/player/components/TimelineOverlays.tsx +12 -0
  54. package/src/player/components/TimelineTypes.ts +4 -0
  55. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  56. package/src/player/components/timelineClipChildren.test.ts +2 -2
  57. package/src/player/components/timelineClipChildren.tsx +2 -1
  58. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  59. package/src/player/components/timelineClipDragCommit.ts +20 -51
  60. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  61. package/src/player/components/timelineClipDragPreview.ts +8 -1
  62. package/src/player/components/timelineCollision.test.ts +23 -0
  63. package/src/player/components/timelineCollision.ts +27 -0
  64. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  65. package/src/player/components/timelineDragDrop.ts +30 -23
  66. package/src/player/components/timelineDragLanding.ts +108 -0
  67. package/src/player/components/timelineDropReveal.test.ts +34 -0
  68. package/src/player/components/timelineDropReveal.ts +12 -0
  69. package/src/player/components/timelineEditCapabilities.ts +1 -2
  70. package/src/player/components/timelineEditing.test.ts +0 -16
  71. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  72. package/src/player/components/timelineGroupEditing.ts +0 -1
  73. package/src/player/components/timelineTrackDisplay.ts +1 -1
  74. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  75. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  76. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  77. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  78. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  79. package/src/player/components/useTimelineGeometry.ts +7 -2
  80. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  81. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  82. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  83. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  84. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  85. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  86. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  87. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  88. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  89. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  90. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  91. package/src/player/lib/describeClips.test.ts +95 -0
  92. package/src/player/lib/describeClips.ts +103 -0
  93. package/src/player/lib/timelineDOM.test.ts +0 -80
  94. package/src/player/lib/timelineDOM.ts +1 -61
  95. package/src/player/lib/timelineElementHelpers.ts +10 -53
  96. package/src/player/store/playerStore.ts +5 -0
  97. package/src/player/store/readinessSlice.ts +4 -3
  98. package/src/player/store/timelineElement.ts +1 -3
  99. package/src/shims/canvasBrowserStub.js +50 -0
  100. package/src/utils/blockInstaller.test.ts +4 -0
  101. package/src/utils/blockInstaller.ts +9 -3
  102. package/src/utils/clipboardPayload.test.ts +42 -2
  103. package/src/utils/clipboardPayload.ts +57 -32
  104. package/src/utils/previewSafeMargins.test.ts +34 -0
  105. package/src/utils/previewSafeMargins.ts +10 -0
  106. package/src/utils/razorSplitTransaction.test.ts +0 -1
  107. package/src/utils/retryBudget.ts +20 -0
  108. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  109. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  110. package/src/utils/sdkCutover.ts +7 -4
  111. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  112. package/src/utils/sdkCutoverEligibility.ts +34 -11
  113. package/src/utils/studioUiPreferences.test.ts +43 -0
  114. package/src/utils/studioUiPreferences.ts +27 -6
  115. package/src/utils/timelineCompositionInsert.ts +2 -1
  116. package/src/utils/timelineElementSplit.test.ts +1 -2
  117. package/src/utils/timelineElementSplit.ts +0 -1
  118. package/src/webmcp/tools/lookTools.test.ts +40 -0
  119. package/src/webmcp/tools/lookTools.ts +32 -1
  120. package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
  121. package/dist/assets/index-B9LfWNyM.css +0 -1
  122. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  123. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  124. 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: buildUniqueCompositionId(block.name, collectHtmlIds(originalContent)),
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 { block, compositionPath: compositionFile };
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 clip payload", () => {
36
+ it("round-trips a single-clip timeline payload", () => {
37
37
  const payload: ClipboardPayload = {
38
38
  kind: "timeline-clip",
39
- html: '<img id="photo" src="a.png" data-start="1" data-duration="3" />',
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
- export interface ClipboardPayload {
6
- kind: "timeline-clip" | "dom-element";
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
- sourceFile: string;
9
- originSelector?: string;
10
- originSelectorIndex?: number;
9
+ start: number;
10
+ duration: number;
11
+ track: number;
11
12
  }
12
13
 
13
- interface SerializedPayload {
14
- _marker: string;
15
- kind: "timeline-clip" | "dom-element";
16
- html: string;
17
- sourceFile: string;
18
- originSelector?: string;
19
- originSelectorIndex?: number;
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 (obj.kind !== "timeline-clip" && obj.kind !== "dom-element") return null;
45
- if (typeof obj.html !== "string" || typeof obj.sourceFile !== "string") return null;
46
- return {
47
- kind: obj.kind,
48
- html: obj.html,
49
- sourceFile: obj.sourceFile,
50
- originSelector: typeof obj.originSelector === "string" ? obj.originSelector : undefined,
51
- originSelectorIndex:
52
- typeof obj.originSelectorIndex === "number" ? obj.originSelectorIndex : undefined,
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(/(?<=\s)id="([^"]+)"/g, (full, id: string) => {
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
+ }
@@ -10,7 +10,6 @@ const element = (over: Partial<TimelineElement> = {}): TimelineElement => ({
10
10
  start: 0,
11
11
  duration: 4,
12
12
  track: 0,
13
- timingSource: "authored",
14
13
  sourceFile: "index.html",
15
14
  ...over,
16
15
  });
@@ -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
- ).resolves.toMatchObject({ status: "declined", reason: "ineligible_operation" });
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
  });
@@ -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
- if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops))
85
- return declinedCutover("ineligible_operation", "dom");
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
- if (!hfId) return declinedCutover("target_unaddressable", "dom");
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
- return (
133
- flagEnabled &&
134
- hasSession &&
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
  }