@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/dist/assets/{hyperframes-player-jDyYPwpC.js → hyperframes-player-CroojEG1.js} +5 -1
- package/dist/assets/{index-DRWtJgb8.js → index-CSyaU1m2.js} +1 -1
- package/dist/assets/{index-BLvqFdnB.js → index-D0AjUHNT.js} +1 -1
- package/dist/assets/{index-hQAWl6re.js → index-PVOehm7h.js} +113 -113
- package/dist/index.html +1 -1
- package/dist/index.js +26 -11
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -0
- package/src/player/components/timelineEditCapabilities.ts +7 -2
- package/src/player/components/timelineGroupEditing.ts +1 -0
- package/src/player/hooks/timelineDomBinding.test.ts +122 -0
- package/src/player/hooks/timelineSyncHydration.ts +4 -7
- package/src/player/lib/timelineElementHelpers.ts +24 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
55
|
-
"@hyperframes/
|
|
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.
|
|
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: {
|
|
27
|
-
|
|
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.
|
|
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
|
|
205
|
+
const resolveHost = iframeDoc ? createTimelineDomNodeResolver(iframeDoc) : null;
|
|
206
206
|
return clips.map((clip, index) => {
|
|
207
|
-
const hostEl =
|
|
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
|
-
|
|
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)
|
|
448
|
+
if (byIdentity && !usedNodes.has(byIdentity) && nodeMatchesClipTag(byIdentity, clip))
|
|
449
|
+
return byIdentity;
|
|
444
450
|
|
|
445
|
-
const candidates =
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|