@hyperframes/studio 0.8.49 → 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 (95) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
  2. package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +12 -4
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5899 -5754
  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/useClipboard.test.ts +234 -0
  35. package/src/hooks/useClipboard.ts +264 -70
  36. package/src/hooks/useGroupCommits.test.ts +0 -1
  37. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  38. package/src/hooks/useRazorSplit.test.ts +0 -2
  39. package/src/hooks/useRazorSplit.test.tsx +0 -1
  40. package/src/hooks/useStudioContextValue.ts +5 -19
  41. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  42. package/src/player/components/ClipContextMenu.tsx +87 -7
  43. package/src/player/components/Timeline.tsx +14 -14
  44. package/src/player/components/TimelineCanvas.tsx +30 -0
  45. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  46. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  47. package/src/player/components/TimelineLanes.test.tsx +22 -1
  48. package/src/player/components/TimelineOverlays.tsx +12 -0
  49. package/src/player/components/TimelineTypes.ts +4 -0
  50. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  51. package/src/player/components/timelineClipChildren.test.ts +2 -2
  52. package/src/player/components/timelineClipChildren.tsx +2 -1
  53. package/src/player/components/timelineClipDragCommit.ts +2 -2
  54. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  55. package/src/player/components/timelineDragDrop.ts +30 -23
  56. package/src/player/components/timelineEditCapabilities.ts +1 -2
  57. package/src/player/components/timelineEditing.test.ts +0 -16
  58. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  59. package/src/player/components/timelineGroupEditing.ts +0 -1
  60. package/src/player/components/timelineTrackDisplay.ts +1 -1
  61. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  62. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  63. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  64. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  65. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  66. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  67. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  68. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  69. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  70. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  71. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  72. package/src/player/lib/describeClips.test.ts +95 -0
  73. package/src/player/lib/describeClips.ts +103 -0
  74. package/src/player/lib/timelineDOM.test.ts +0 -80
  75. package/src/player/lib/timelineDOM.ts +1 -61
  76. package/src/player/lib/timelineElementHelpers.ts +2 -53
  77. package/src/player/store/playerStore.ts +5 -0
  78. package/src/player/store/timelineElement.ts +1 -3
  79. package/src/shims/canvasBrowserStub.js +50 -0
  80. package/src/utils/clipboardPayload.test.ts +42 -2
  81. package/src/utils/clipboardPayload.ts +57 -32
  82. package/src/utils/previewSafeMargins.test.ts +34 -0
  83. package/src/utils/previewSafeMargins.ts +10 -0
  84. package/src/utils/razorSplitTransaction.test.ts +0 -1
  85. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  86. package/src/utils/studioUiPreferences.test.ts +43 -0
  87. package/src/utils/studioUiPreferences.ts +27 -6
  88. package/src/utils/timelineElementSplit.test.ts +1 -2
  89. package/src/utils/timelineElementSplit.ts +0 -1
  90. package/src/webmcp/tools/lookTools.test.ts +40 -0
  91. package/src/webmcp/tools/lookTools.ts +32 -1
  92. package/dist/assets/index-B9LfWNyM.css +0 -1
  93. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  94. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  95. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  createTimelineElementFromManifestClip,
5
5
  parseTimelineFromDOM,
6
- createImplicitTimelineLayersFromDOM,
7
6
  mergeTimelineElementsPreservingDowngrades,
8
7
  } from "./timelineDOM";
9
8
  import { isTimelineIgnoredElement } from "./timelineElementHelpers";
@@ -302,19 +301,6 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident
302
301
  // implicit-layer fallback happily gave it a track. Draggable and trimmable, and
303
302
  // writing timing onto a bus means nothing.
304
303
  describe("<hf-audio-group> is not a timeline layer", () => {
305
- it("gets no implicit row of its own", () => {
306
- const doc = makeDoc(`
307
- <div data-composition-id="root">
308
- <audio id="voice-1" data-start="0" data-duration="6" data-audio-group="voiceover"></audio>
309
- <hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
310
- </div>
311
- `);
312
-
313
- const implicit = createImplicitTimelineLayersFromDOM(doc, 12, []);
314
-
315
- expect(implicit.map((el) => el.domId)).not.toContain("voiceover");
316
- });
317
-
318
304
  it("is excluded by the shared ignore predicate", () => {
319
305
  const doc = makeDoc(`<hf-audio-group id="vo"></hf-audio-group><div id="panel"></div>`);
320
306
  expect(isTimelineIgnoredElement(doc.getElementById("vo") as Element)).toBe(true);
@@ -322,72 +308,6 @@ describe("<hf-audio-group> is not a timeline layer", () => {
322
308
  });
323
309
  });
324
310
 
325
- describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
326
- it("uses the runtime root paint scope for implicit siblings of manifest clips", () => {
327
- const doc = makeDoc(`
328
- <div data-composition-id="root">
329
- <div id="timed" data-start="0" data-duration="5"></div>
330
- <div id="implicit"></div>
331
- </div>
332
- `);
333
- const timedHost = doc.getElementById("timed");
334
- const timed = createTimelineElementFromManifestClip({
335
- clip: {
336
- id: "timed",
337
- label: "Timed",
338
- start: 0,
339
- duration: 5,
340
- track: 0,
341
- stackingContextId: "css:root",
342
- kind: "element",
343
- tagName: "div",
344
- compositionId: null,
345
- parentCompositionId: null,
346
- compositionSrc: null,
347
- assetUrl: null,
348
- },
349
- fallbackIndex: 0,
350
- doc,
351
- hostEl: timedHost,
352
- });
353
- const implicit = createImplicitTimelineLayersFromDOM(doc, 5, [timed])[0];
354
-
355
- expect(implicit?.stackingContextId).toBe("css:root");
356
- expect(implicit?.stackingContextId).toBe(timed.stackingContextId);
357
- });
358
-
359
- it("harvests hfId from an implicit layer child that has data-hf-id", () => {
360
- const doc = makeDoc(`
361
- <div data-composition-id="root">
362
- <div id="layer" class="clip" data-hf-id="hf-xyz789"></div>
363
- </div>
364
- `);
365
-
366
- const layers = createImplicitTimelineLayersFromDOM(doc, 10);
367
- const layer = layers.find((el) => el.domId === "layer");
368
-
369
- expect(layer).toBeDefined();
370
- expect(layer?.hfId).toBe("hf-xyz789");
371
- });
372
-
373
- it("ignores runtime-owned color grading canvases as implicit layers", () => {
374
- const doc = makeDoc(`
375
- <div data-composition-id="root" data-duration="5">
376
- <img id="photo" class="clip" data-start="0" data-duration="5" />
377
- <canvas
378
- class="__hf_color_grading_canvas__"
379
- data-hf-color-grading-canvas="true"
380
- data-hyperframes-ignore
381
- ></canvas>
382
- </div>
383
- `);
384
-
385
- const layers = createImplicitTimelineLayersFromDOM(doc, 5);
386
-
387
- expect(layers).toEqual([]);
388
- });
389
- });
390
-
391
311
  describe("mergeTimelineElementsPreservingDowngrades — genuine removal vs transient downgrade", () => {
392
312
  it("drops a removed TOP-LEVEL element (undo of a split) instead of ghosting it", () => {
393
313
  const current = [el("a"), el("a-split")]; // post-split store: original + clone
@@ -17,8 +17,6 @@ import {
17
17
  resolveMediaElement,
18
18
  applyMediaMetadataFromElement,
19
19
  getTimelineElementDisplayLabel,
20
- getImplicitTimelineLayerLabel,
21
- isImplicitTimelineLayerCandidate,
22
20
  getTimelineElementSelector,
23
21
  getTimelineElementSourceFile,
24
22
  getTimelineElementSelectorIndex,
@@ -203,63 +201,6 @@ export function createTimelineElementFromManifestClip(params: {
203
201
  return entry;
204
202
  }
205
203
 
206
- export function createImplicitTimelineLayersFromDOM(
207
- doc: Document,
208
- rootDuration: number,
209
- existingElements: readonly TimelineElement[] = [],
210
- ): TimelineElement[] {
211
- if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
212
- const rootComp = doc.querySelector("[data-composition-id]");
213
- if (!rootComp) return [];
214
-
215
- const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
216
- const maxTrack = existingElements.reduce(
217
- (max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
218
- -1,
219
- );
220
- const layers: TimelineElement[] = [];
221
-
222
- for (const child of Array.from(rootComp.children)) {
223
- if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
224
-
225
- const selector = getTimelineElementSelector(child);
226
- if (!selector) continue;
227
- const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
228
- const sourceFile = getTimelineElementSourceFile(child);
229
- const label = getImplicitTimelineLayerLabel(child);
230
- const identity = buildTimelineElementIdentity({
231
- preferredId: child.id || null,
232
- label,
233
- fallbackIndex: existingElements.length + layers.length,
234
- domId: child.id || undefined,
235
- selector,
236
- selectorIndex,
237
- sourceFile,
238
- });
239
- if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
240
-
241
- layers.push({
242
- domId: child.id || undefined,
243
- hfId: child.getAttribute("data-hf-id") || undefined,
244
- zIndex: readTimelineElementZIndex(child),
245
- duration: rootDuration,
246
- id: identity.id,
247
- key: identity.key,
248
- label,
249
- selector,
250
- selectorIndex,
251
- sourceFile,
252
- stackingContextId: resolveCssStackingContextId(child),
253
- start: 0,
254
- tag: child.tagName.toLowerCase(),
255
- timingSource: "implicit",
256
- track: maxTrack + 1 + layers.length,
257
- });
258
- }
259
-
260
- return layers;
261
- }
262
-
263
204
  /**
264
205
  * Parse [data-start] elements from a Document into TimelineElement[].
265
206
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
@@ -330,7 +271,6 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
330
271
  selectorIndex,
331
272
  sourceFile,
332
273
  stackingContextId: resolveCssStackingContextId(el),
333
- timingSource: "authored",
334
274
  zIndex: readTimelineElementZIndex(el),
335
275
  };
336
276
 
@@ -399,7 +339,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
399
339
  els.push(entry);
400
340
  });
401
341
 
402
- return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
342
+ return els;
403
343
  }
404
344
 
405
345
  // ---------------------------------------------------------------------------
@@ -2,7 +2,7 @@
2
2
  * Low-level helpers for building and identifying TimelineElement objects.
3
3
  *
4
4
  * Covers: duration reading, media-element metadata extraction, selector/key/
5
- * identity builders, DOM node lookup, and implicit layer detection. These are
5
+ * identity builders, and DOM node lookup. These are
6
6
  * intentionally dependency-free (no store, no hooks) so they can be used in
7
7
  * both the React hook and test environments.
8
8
  */
@@ -82,13 +82,7 @@ function normalizePlaybackRate(raw: number): number {
82
82
  }
83
83
 
84
84
  export function isTimelineIgnoredElement(el: Element): boolean {
85
- // An `<hf-audio-group>` is a mixer bus, not a clip: it carries the group's
86
- // label, fader, mute and FX chain, has no timing of its own, and is drawn as
87
- // a GROUP ROW by the group derivation. Left in, the implicit-layer fallback
88
- // also gave it an ordinary full-duration track — so a grouped composition
89
- // showed "Voiceover • 0.0s – 12.0s" as a phantom clip directly above the real
90
- // group header. Harmless-looking, but that row is draggable and trimmable,
91
- // and writing timing onto the bus is meaningless.
85
+ // An `<hf-audio-group>` is a mixer bus with no timing of its own, drawn as a group row, never a clip.
92
86
  if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) return true;
93
87
  return Boolean(
94
88
  el.closest(
@@ -223,37 +217,6 @@ export function getTimelineElementDisplayLabel(input: {
223
217
  return tag ? `${tag} clip` : "Timeline clip";
224
218
  }
225
219
 
226
- const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
227
- "base",
228
- "link",
229
- "meta",
230
- "noscript",
231
- "script",
232
- "style",
233
- "template",
234
- ]);
235
-
236
- function humanizeTimelineIdentifier(value: string): string {
237
- return value
238
- .trim()
239
- .replace(/[_-]+/g, " ")
240
- .replace(/\s+/g, " ")
241
- .replace(/\b\w/g, (match) => match.toUpperCase());
242
- }
243
-
244
- export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
245
- const explicitLabel =
246
- el.getAttribute("data-timeline-label") ??
247
- el.getAttribute("data-label") ??
248
- el.getAttribute("aria-label");
249
- if (explicitLabel?.trim()) return explicitLabel.trim();
250
- if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
251
- const classes = el.className.split(/\s+/).filter(Boolean);
252
- const className = classes.find((value) => value !== "clip") ?? classes[0];
253
- if (className) return humanizeTimelineIdentifier(className);
254
- return getTimelineElementDisplayLabel({ tag: el.tagName });
255
- }
256
-
257
220
  // ---------------------------------------------------------------------------
258
221
  // Selector / identity / key builders
259
222
  // ---------------------------------------------------------------------------
@@ -475,17 +438,3 @@ export function createTimelineDomNodeResolver(doc: Document) {
475
438
  return node;
476
439
  };
477
440
  }
478
-
479
- // ---------------------------------------------------------------------------
480
- // Implicit layer detection
481
- // ---------------------------------------------------------------------------
482
-
483
- export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
484
- if (!isHtmlElement(el)) return false;
485
- if (isTimelineIgnoredElement(el)) return false;
486
- if (el.parentElement !== root) return false;
487
- const tagName = el.tagName.toLowerCase();
488
- if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
489
- if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
490
- return Boolean(getTimelineElementSelector(el));
491
- }
@@ -200,6 +200,8 @@ interface PlayerState extends PlayerStoreSlices {
200
200
  clipManifest: ClipManifestClip[] | null;
201
201
  setClipManifest: (clips: ClipManifestClip[] | null) => void;
202
202
  clipParentMap: Map<string, string>;
203
+ topLevelIds: ReadonlySet<string> | null;
204
+ setTopLevelIds: (ids: ReadonlySet<string> | null) => void;
203
205
  setClipParentMap: (map: Map<string, string>) => void;
204
206
  /**
205
207
  * Sub-composition DOM descendants (groups + their children) that have no
@@ -288,6 +290,7 @@ export function createTimelineResetState() {
288
290
  beatPersist: null,
289
291
  clipManifest: null,
290
292
  clipParentMap: new Map<string, string>(),
293
+ topLevelIds: null,
291
294
  domClipChildren: [],
292
295
  subCompositionHostState: new Map<string, SubCompositionHostState>(),
293
296
  };
@@ -434,6 +437,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
434
437
  setClipManifest: (clips) => set({ clipManifest: clips }),
435
438
  clipParentMap: new Map(),
436
439
  setClipParentMap: (map) => set({ clipParentMap: map }),
440
+ topLevelIds: null,
441
+ setTopLevelIds: (ids) => set({ topLevelIds: ids }),
437
442
  domClipChildren: [],
438
443
  setDomClipChildren: (children) => set({ domClipChildren: children }),
439
444
  subCompositionHostState: new Map(),
@@ -59,8 +59,6 @@ export interface TimelineElement {
59
59
  automation?: string;
60
60
  /** Path from data-composition-src — identifies sub-composition elements */
61
61
  compositionSrc?: string;
62
- /** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
63
- timingSource?: "authored" | "implicit";
64
62
  /** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
65
63
  timelineLocked?: boolean;
66
64
  /** Set by data-hidden on the host element — hides the clip in preview and render. */
@@ -79,7 +77,7 @@ export interface TimelineElement {
79
77
  audioGroupFxChain?: string;
80
78
  audioGroupAutomation?: string;
81
79
  /**
82
- * Set by useExpandedTimelineElements on an inline-expanded sub-composition
80
+ * Set by useTimelineRowElements on an inline-expanded sub-composition
83
81
  * child: the absolute master-timeline start of the sub-comp host the child
84
82
  * lives in. Presence marks the element as expanded; edits subtract it to get
85
83
  * the child's local (sourceFile-relative) time. Works at any nesting depth.
@@ -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 };
@@ -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
  });