@hyperframes/studio 0.8.30 → 0.8.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/player": "0.8.30",
52
- "@hyperframes/core": "0.8.30",
53
- "@hyperframes/sdk": "0.8.30",
54
- "@hyperframes/studio-server": "0.8.30",
55
- "@hyperframes/parsers": "0.8.30"
51
+ "@hyperframes/core": "0.8.32",
52
+ "@hyperframes/parsers": "0.8.32",
53
+ "@hyperframes/player": "0.8.32",
54
+ "@hyperframes/studio-server": "0.8.32",
55
+ "@hyperframes/sdk": "0.8.32"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^3.2.4",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.30"
72
+ "@hyperframes/producer": "0.8.32"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
@@ -10,6 +10,7 @@ export function canMoveTimelineElement(element: TimelineElement): boolean {
10
10
  kind: element.kind,
11
11
  duration: element.duration,
12
12
  domId: element.domId,
13
+ hfId: element.hfId,
13
14
  selector: element.selector,
14
15
  compositionSrc: element.compositionSrc,
15
16
  playbackStart: element.playbackStart,
@@ -23,8 +23,12 @@ function isDeterministicTimelineWindow(input: {
23
23
  return ["video", "audio", "img"].includes(input.tag.toLowerCase());
24
24
  }
25
25
 
26
- export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean {
27
- return Boolean(input.domId || input.selector);
26
+ export function hasPatchableTimelineTarget(input: {
27
+ domId?: string;
28
+ hfId?: string;
29
+ selector?: string;
30
+ }): boolean {
31
+ return Boolean(input.domId || input.hfId || input.selector);
28
32
  }
29
33
 
30
34
  export function getTimelineEditCapabilities(input: {
@@ -32,6 +36,7 @@ export function getTimelineEditCapabilities(input: {
32
36
  kind?: "video" | "audio" | "image" | "element" | "composition";
33
37
  duration: number;
34
38
  domId?: string;
39
+ hfId?: string;
35
40
  selector?: string;
36
41
  compositionSrc?: string;
37
42
  playbackStart?: number;
@@ -192,6 +192,7 @@ function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): b
192
192
  kind: element.kind,
193
193
  duration: element.duration,
194
194
  domId: element.domId,
195
+ hfId: element.hfId,
195
196
  selector: element.selector,
196
197
  compositionSrc: element.compositionSrc,
197
198
  playbackStart: element.playbackStart,
@@ -0,0 +1,122 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { Window } from "happy-dom";
5
+ import { buildTimelineElementsFromClips } from "./timelineSyncHydration";
6
+ import { findTimelineDomNodeForClip } from "../lib/timelineElementHelpers";
7
+ import { canMoveTimelineElement } from "../components/timelineAuthoredMoveTarget";
8
+ import { buildTimelineGroupResizeMembers } from "../components/timelineGroupEditing";
9
+ import { getTimelineEditCapabilities } from "../components/timelineEditCapabilities";
10
+ import type { ClipManifestClip } from "../lib/playbackTypes";
11
+
12
+ const clip = (overrides: Partial<ClipManifestClip> = {}): ClipManifestClip => ({
13
+ id: null,
14
+ label: "",
15
+ start: 0,
16
+ duration: 4,
17
+ track: 0,
18
+ kind: "element",
19
+ tagName: "div",
20
+ compositionId: null,
21
+ parentCompositionId: null,
22
+ compositionSrc: null,
23
+ assetUrl: null,
24
+ ...overrides,
25
+ });
26
+ function documentWith(body: string): Document {
27
+ const window = new Window();
28
+ Object.assign(window, { SyntaxError });
29
+ window.document.body.innerHTML = `<div data-composition-id="main" data-duration="10">${body}</div>`;
30
+ return window.document;
31
+ }
32
+ describe("live DOM manifest hydration", () => {
33
+ it("carries hfId through authored move and group trim capability callers", () => {
34
+ const elements = ["a", "b"].map((id) => ({
35
+ id,
36
+ key: id,
37
+ tag: "div",
38
+ start: 0,
39
+ duration: 4,
40
+ track: 0,
41
+ hfId: id,
42
+ }));
43
+ expect(canMoveTimelineElement(elements[0])).toBe(true);
44
+ for (const edge of ["start", "end"] as const) {
45
+ expect(
46
+ buildTimelineGroupResizeMembers(elements, new Set(["a", "b"]), "a", edge),
47
+ ).toHaveLength(2);
48
+ }
49
+ });
50
+ it("does not bind a cross-tag direct identity", () => {
51
+ const doc = documentWith('<div id="collision" data-start="0" data-duration="4"></div>');
52
+ expect(
53
+ findTimelineDomNodeForClip(doc, clip({ id: "collision", tagName: "img" }), 0),
54
+ ).toBeNull();
55
+ });
56
+ it("does not let an unmatched image steal the scene's editable identity", () => {
57
+ const doc = documentWith(
58
+ '<div class="clip scene" data-hf-id="scene-hf" data-start="2" data-duration="4"></div>',
59
+ );
60
+ const elements = buildTimelineElementsFromClips(
61
+ [clip({ id: "missing", tagName: "img", kind: "image" }), clip({ start: 2 })],
62
+ doc,
63
+ );
64
+ expect(elements[0].hfId).toBeUndefined();
65
+ expect(elements[1].hfId).toBe("scene-hf");
66
+ expect(getTimelineEditCapabilities(elements[1]).canMove).toBe(true);
67
+ });
68
+ it("allows hfId-only authored clips to move and trim while preserving locks", () => {
69
+ const input = { tag: "div", duration: 4, hfId: "scene-hf" };
70
+ expect(getTimelineEditCapabilities(input)).toEqual({
71
+ canMove: true,
72
+ canTrimStart: true,
73
+ canTrimEnd: true,
74
+ });
75
+ expect(getTimelineEditCapabilities({ ...input, timelineLocked: true }).canMove).toBe(false);
76
+ expect(getTimelineEditCapabilities({ ...input, timingSource: "implicit" }).canTrimEnd).toBe(
77
+ false,
78
+ );
79
+ });
80
+ it("uses at most one fallback candidate snapshot per hydration pass", () => {
81
+ const doc = documentWith(
82
+ Array.from(
83
+ { length: 300 },
84
+ (_, i) => `<div data-hf-id="h${i}" data-start="${i}" data-duration="4"></div>`,
85
+ ).join(""),
86
+ );
87
+ const query = vi.spyOn(doc, "querySelectorAll");
88
+ const clips = Array.from({ length: 300 }, (_, i) => clip({ start: i }));
89
+ expect(new Set(buildTimelineElementsFromClips(clips, doc).map((e) => e.hfId)).size).toBe(300);
90
+ expect(query.mock.calls.filter(([selector]) => selector === "[data-start]")).toHaveLength(1);
91
+ });
92
+ it("avoids candidate scans for hundreds of direct DOM and hf identities", () => {
93
+ const doc = documentWith(
94
+ Array.from(
95
+ { length: 300 },
96
+ (_, i) =>
97
+ `<div ${i % 2 ? "id" : "data-hf-id"}="direct${i}" data-start="${i}" data-duration="4"></div>`,
98
+ ).join(""),
99
+ );
100
+ const query = vi.spyOn(doc, "querySelectorAll");
101
+ const clips = Array.from({ length: 300 }, (_, i) => clip({ id: `direct${i}`, start: i }));
102
+ expect(buildTimelineElementsFromClips(clips, doc)).toHaveLength(300);
103
+ expect(query.mock.calls.filter(([selector]) => selector === "[data-start]")).toHaveLength(0);
104
+ });
105
+ it("resolves hf identities without a fallback scan and refreshes after DOM replacement", () => {
106
+ const doc = documentWith('<div data-hf-id="stable" data-start="2" data-duration="4"></div>');
107
+ const query = vi.spyOn(doc, "querySelectorAll");
108
+ const clips = [clip({ id: "stable", start: 0 })];
109
+ expect(buildTimelineElementsFromClips(clips, doc)[0].hfId).toBe("stable");
110
+ expect(query.mock.calls.filter(([selector]) => selector === "[data-start]")).toHaveLength(0);
111
+ doc.querySelector("[data-composition-id]")!.innerHTML =
112
+ '<div data-hf-id="replacement" data-start="2" data-duration="4"></div>';
113
+ expect(buildTimelineElementsFromClips([clip({ start: 2 })], doc)[0].hfId).toBe("replacement");
114
+ });
115
+ it("preserves DOM-id precedence and same-tag positional recovery after timing drift", () => {
116
+ const doc = documentWith(
117
+ '<div id="stable" data-start="5"></div><div data-hf-id="stable" data-start="0"></div>',
118
+ );
119
+ expect(findTimelineDomNodeForClip(doc, clip({ id: "stable" }), 0)?.id).toBe("stable");
120
+ expect(findTimelineDomNodeForClip(doc, clip({ start: 9 }), 0)?.id).toBe("stable");
121
+ });
122
+ });
@@ -9,6 +9,7 @@
9
9
  * an argument, so each is callable — and readable — on its own.
10
10
  */
11
11
 
12
+ import { createTimelineDomNodeResolver } from "../lib/timelineElementHelpers";
12
13
  import { usePlayerStore } from "../store/playerStore";
13
14
  import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
14
15
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
@@ -20,7 +21,6 @@ import {
20
21
  buildStandaloneRootTimelineElement,
21
22
  createImplicitTimelineLayersFromDOM,
22
23
  createTimelineElementFromManifestClip,
23
- findTimelineDomNodeForClip,
24
24
  getTimelineElementSelector,
25
25
  parseTimelineFromDOM,
26
26
  } from "../lib/timelineDOM";
@@ -195,19 +195,16 @@ export function safeContentDocument(iframe: HTMLIFrameElement | null): Document
195
195
 
196
196
  /**
197
197
  * The manifest's root clips as TimelineElements, each bound to the live DOM node
198
- * it was authored as. `usedHostEls` makes the binding one-to-one: two clips with
198
+ * it was authored as. The pass-scoped resolver makes the binding one-to-one: two clips with
199
199
  * the same shape must not both claim the same element.
200
200
  */
201
201
  export function buildTimelineElementsFromClips(
202
202
  clips: readonly ClipManifestClip[],
203
203
  iframeDoc: Document | null,
204
204
  ): TimelineElement[] {
205
- const usedHostEls = new Set<Element>();
205
+ const resolveHost = iframeDoc ? createTimelineDomNodeResolver(iframeDoc) : null;
206
206
  return clips.map((clip, index) => {
207
- const hostEl = iframeDoc
208
- ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
209
- : null;
210
- if (hostEl) usedHostEls.add(hostEl);
207
+ const hostEl = resolveHost?.(clip, index) ?? null;
211
208
  return createTimelineElementFromManifestClip({
212
209
  clip,
213
210
  fallbackIndex: index,
@@ -413,9 +413,12 @@ const MANIFEST_CLIP_ATTRS: ReadonlyArray<[string, (clip: ClipManifestClip) => nu
413
413
  ["data-track-index", (clip) => clip.track],
414
414
  ];
415
415
 
416
+ function nodeMatchesClipTag(node: Element, clip: ClipManifestClip): boolean {
417
+ return !clip.tagName || node.tagName.toLowerCase() === clip.tagName.toLowerCase();
418
+ }
419
+
416
420
  function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean {
417
- const tagName = clip.tagName?.toLowerCase();
418
- if (tagName && node.tagName.toLowerCase() !== tagName) return false;
421
+ if (!nodeMatchesClipTag(node, clip)) return false;
419
422
  // An attribute only constrains the match when it parses to a finite number:
420
423
  // missing or garbled reads as "unknown", not "mismatch".
421
424
  return MANIFEST_CLIP_ATTRS.every(([attr, expected]) => {
@@ -427,6 +430,7 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
427
430
  function findTimelineDomNode(doc: Document, id: string): Element | null {
428
431
  return (
429
432
  doc.getElementById(id) ??
433
+ doc.querySelector(`[data-hf-id="${CSS.escape(id)}"]`) ??
430
434
  doc.querySelector(`[data-composition-id="${CSS.escape(id)}"]`) ??
431
435
  doc.querySelector(`.${CSS.escape(id)}`) ??
432
436
  null
@@ -438,15 +442,30 @@ export function findTimelineDomNodeForClip(
438
442
  clip: ClipManifestClip,
439
443
  fallbackIndex: number,
440
444
  usedNodes = new Set<Element>(),
445
+ getCandidates = () => getTimelineDomNodes(doc),
441
446
  ): Element | null {
442
447
  const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
443
- if (byIdentity && !usedNodes.has(byIdentity)) return byIdentity;
448
+ if (byIdentity && !usedNodes.has(byIdentity) && nodeMatchesClipTag(byIdentity, clip))
449
+ return byIdentity;
444
450
 
445
- const candidates = getTimelineDomNodes(doc).filter((node) => !usedNodes.has(node));
451
+ const candidates = getCandidates().filter((node) => !usedNodes.has(node));
446
452
  const exact = candidates.find((node) => nodeMatchesManifestClip(node, clip));
447
453
  if (exact) return exact;
448
454
 
449
- return candidates[fallbackIndex] ?? null;
455
+ const positional = candidates[fallbackIndex];
456
+ return positional && nodeMatchesClipTag(positional, clip) ? positional : null;
457
+ }
458
+
459
+ /** One synchronous hydration pass: snapshot only on a miss, never across reloads. */
460
+ export function createTimelineDomNodeResolver(doc: Document) {
461
+ let candidates: Element[] | undefined;
462
+ const usedNodes = new Set<Element>();
463
+ const getCandidates = () => (candidates ??= getTimelineDomNodes(doc));
464
+ return (clip: ClipManifestClip, index: number): Element | null => {
465
+ const node = findTimelineDomNodeForClip(doc, clip, index, usedNodes, getCandidates);
466
+ if (node) usedNodes.add(node);
467
+ return node;
468
+ };
450
469
  }
451
470
 
452
471
  // ---------------------------------------------------------------------------