@hyperframes/studio 0.8.48 → 0.8.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-CxxDxHTW.js +464 -0
- package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6209 -5924
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +20 -51
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +10 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
describe("processTimelineMessage with an empty manifest", () => {
|
|
12
|
+
it("commits zero rows without adopting the runtime 1s floor as the duration", () => {
|
|
13
|
+
const sync = vi.fn<(els: TimelineElement[], duration?: number) => void>();
|
|
14
|
+
let processTimelineMessage: ReturnType<
|
|
15
|
+
typeof useTimelineSyncCallbacks
|
|
16
|
+
>["processTimelineMessage"];
|
|
17
|
+
function Harness() {
|
|
18
|
+
({ processTimelineMessage } = useTimelineSyncCallbacks({
|
|
19
|
+
iframeRef: { current: null },
|
|
20
|
+
probeIntervalRef: { current: undefined },
|
|
21
|
+
pendingSeekRef: { current: null },
|
|
22
|
+
isRefreshingRef: { current: false },
|
|
23
|
+
getAdapter: () => null,
|
|
24
|
+
syncTimelineElements: sync,
|
|
25
|
+
setDuration: () => {},
|
|
26
|
+
setCurrentTime: () => {},
|
|
27
|
+
requestTimelineReady: () => {},
|
|
28
|
+
setIsPlaying: () => {},
|
|
29
|
+
attachIframeShortcutListeners: () => {},
|
|
30
|
+
applyPreviewAudioState: () => {},
|
|
31
|
+
}));
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const host = document.createElement("div");
|
|
35
|
+
act(() => createRoot(host).render(<Harness />));
|
|
36
|
+
|
|
37
|
+
processTimelineMessage!({ clips: [], durationInFrames: 30 });
|
|
38
|
+
|
|
39
|
+
expect(sync).toHaveBeenCalledWith([], undefined);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -17,7 +17,9 @@ import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
|
|
|
17
17
|
import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
|
|
18
18
|
import {
|
|
19
19
|
buildTimelineElementsFromClips,
|
|
20
|
+
syncManifestTimeline,
|
|
20
21
|
clipTreeParentMap,
|
|
22
|
+
collectTopLevelElementIds,
|
|
21
23
|
collectSubCompositionDomChildren,
|
|
22
24
|
collectSubCompositionHostState,
|
|
23
25
|
hydrateTimelineFromPreview,
|
|
@@ -26,7 +28,6 @@ import {
|
|
|
26
28
|
sanitizeDurationSeconds,
|
|
27
29
|
seekAdapterToRestorePoint,
|
|
28
30
|
syncAdapterDuration,
|
|
29
|
-
withImplicitDomLayers,
|
|
30
31
|
type RuntimeTimelineMessage,
|
|
31
32
|
} from "./timelineSyncHydration";
|
|
32
33
|
|
|
@@ -114,9 +115,7 @@ export function useTimelineSyncCallbacks({
|
|
|
114
115
|
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
115
116
|
const processTimelineMessage = useCallback(
|
|
116
117
|
(data: RuntimeTimelineMessage) => {
|
|
117
|
-
if (!data.clips
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
118
|
+
if (!data.clips) return;
|
|
120
119
|
|
|
121
120
|
usePlayerStore.getState().setClipManifest(data.clips);
|
|
122
121
|
|
|
@@ -131,11 +130,13 @@ export function useTimelineSyncCallbacks({
|
|
|
131
130
|
const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null);
|
|
132
131
|
const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
|
|
133
132
|
usePlayerStore.getState().setClipParentMap(parentMap);
|
|
133
|
+
usePlayerStore.getState().setTopLevelIds(collectTopLevelElementIds(iframeDoc));
|
|
134
134
|
usePlayerStore.getState().setDomClipChildren(domClipChildren);
|
|
135
135
|
usePlayerStore
|
|
136
136
|
.getState()
|
|
137
137
|
.setSubCompositionHostState(collectSubCompositionHostState(iframeDoc, data.clips));
|
|
138
138
|
} catch {
|
|
139
|
+
usePlayerStore.getState().setTopLevelIds(null);
|
|
139
140
|
// cross-origin or __clipTree not available — maps stay empty
|
|
140
141
|
}
|
|
141
142
|
|
|
@@ -150,14 +151,12 @@ export function useTimelineSyncCallbacks({
|
|
|
150
151
|
manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data),
|
|
151
152
|
authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
|
|
152
153
|
});
|
|
153
|
-
|
|
154
|
+
syncManifestTimeline(
|
|
154
155
|
els,
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
newDuration,
|
|
157
|
+
usePlayerStore.getState().duration,
|
|
158
|
+
syncTimelineElements,
|
|
157
159
|
);
|
|
158
|
-
if (timelineEls.length > 0) {
|
|
159
|
-
syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
|
|
160
|
-
}
|
|
161
160
|
},
|
|
162
161
|
[iframeRef, syncTimelineElements],
|
|
163
162
|
);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
3
|
+
import { describeClips, formatTimelineBlock } from "./describeClips";
|
|
4
|
+
|
|
5
|
+
function audio(id: string, start: number, duration: number, extra: Partial<TimelineElement> = {}) {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
domId: id,
|
|
9
|
+
tag: "audio",
|
|
10
|
+
kind: "audio",
|
|
11
|
+
start,
|
|
12
|
+
duration,
|
|
13
|
+
track: 0,
|
|
14
|
+
...extra,
|
|
15
|
+
} satisfies TimelineElement;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Shaped like claude-paper-launch: two voiceovers, clicks, and dozens of keystroke sounds, each on its own track.
|
|
19
|
+
function paperLaunchClips(): TimelineElement[] {
|
|
20
|
+
const clips = [
|
|
21
|
+
audio("vo", 29.6, 2.17, { src: "voiceover.mp3", authoredTrack: 8, volume: 0.009772 }),
|
|
22
|
+
audio("sfx-click-0", 1.6, 0.07, { src: "click.mp3", authoredTrack: 100, volume: 0.85 }),
|
|
23
|
+
audio("sfx-type-0", 7.8, 0.57, { src: "typenew.mp3", authoredTrack: 187, volume: 0.2 }),
|
|
24
|
+
];
|
|
25
|
+
for (let i = 1; i <= 84; i++) {
|
|
26
|
+
clips.push(audio(`sfx-extra-${i}`, 8 + i * 0.1, 0.5, { authoredTrack: 200 + i, volume: 0.2 }));
|
|
27
|
+
}
|
|
28
|
+
return clips;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("formatTimelineBlock", () => {
|
|
32
|
+
it("lists every audio clip with its start and volume", () => {
|
|
33
|
+
const block = formatTimelineBlock(paperLaunchClips());
|
|
34
|
+
const audioLines = block.split("\n").filter((line) => line.startsWith("- audio "));
|
|
35
|
+
expect(audioLines).toHaveLength(87);
|
|
36
|
+
expect(
|
|
37
|
+
audioLines.every((line) => /start=[\d.]+/.test(line) && /volume=[\d.]+/.test(line)),
|
|
38
|
+
).toBe(true);
|
|
39
|
+
expect(block).toContain(
|
|
40
|
+
'- audio "sfx-click-0" src=click.mp3 start=1.6 duration=0.07 end=1.67 track=100 volume=0.85',
|
|
41
|
+
);
|
|
42
|
+
expect(block).toContain(
|
|
43
|
+
'- audio "vo" src=voiceover.mp3 start=29.6 duration=2.17 end=31.77 track=8 volume=0.01',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("orders clips by start time", () => {
|
|
48
|
+
const lines = formatTimelineBlock(paperLaunchClips()).split("\n");
|
|
49
|
+
expect(lines[1]).toContain('"sfx-click-0"');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("prints volume automation points, rate and group", () => {
|
|
53
|
+
const block = formatTimelineBlock([
|
|
54
|
+
audio("bed", 8, 20, {
|
|
55
|
+
automation:
|
|
56
|
+
'{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":0.1},{"t":10,"v":0.5}]}]}',
|
|
57
|
+
playbackRate: 0.5,
|
|
58
|
+
audioGroup: "music",
|
|
59
|
+
timelineRole: "music",
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
expect(block).toContain("rate=0.5 group=music role=music volume-lane=[0:0.1, 10:0.5]");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("prints volume 0 and a project-relative src, and omits rate at normal speed", () => {
|
|
66
|
+
const block = formatTimelineBlock([
|
|
67
|
+
audio("mute", 0, 1, {
|
|
68
|
+
src: "http://localhost:5190/api/projects/p1/preview/assets/a.mp3",
|
|
69
|
+
volume: 0,
|
|
70
|
+
playbackRate: 1,
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
expect(block).toContain(
|
|
74
|
+
'- audio "mute" src=assets/a.mp3 start=0 duration=1 end=1 track=0 volume=0',
|
|
75
|
+
);
|
|
76
|
+
expect(block).not.toContain("rate=");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("is empty for an empty timeline and caps long ones", () => {
|
|
80
|
+
expect(formatTimelineBlock([])).toBe("");
|
|
81
|
+
const many = Array.from({ length: 250 }, (_, i) => audio(`a${i}`, i, 1));
|
|
82
|
+
expect(formatTimelineBlock(many)).toContain("(50 more clips not listed)");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("describeClips", () => {
|
|
87
|
+
it("keeps 'not authored' distinct from zero for volume and rate", () => {
|
|
88
|
+
const [plain, silent] = describeClips([
|
|
89
|
+
audio("a", 0, 1),
|
|
90
|
+
audio("b", 1, 1, { volume: 0, playbackRate: 0 }),
|
|
91
|
+
]);
|
|
92
|
+
expect([plain?.volume, plain?.playbackRate]).toEqual([null, null]);
|
|
93
|
+
expect([silent?.volume, silent?.playbackRate]).toEqual([0, 0]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Plain-field clip facts from the player store; feeds the Ask-agent prompt and `studio_look`. */
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { elementAutomationLanes } from "../components/automationLaneData";
|
|
4
|
+
import { roundTo3 } from "../../utils/rounding";
|
|
5
|
+
|
|
6
|
+
export interface ClipLane {
|
|
7
|
+
/** `volume`, or `fx.<nodeId>.<param>`. */
|
|
8
|
+
target: string;
|
|
9
|
+
/** `t` is seconds from the start of the clip, not the composition. */
|
|
10
|
+
points: { t: number; v: number }[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ClipFact {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string | null;
|
|
16
|
+
kind: string;
|
|
17
|
+
start: number;
|
|
18
|
+
duration: number;
|
|
19
|
+
end: number;
|
|
20
|
+
/** The `data-track-index` as written in the source file. */
|
|
21
|
+
trackIndex: number;
|
|
22
|
+
src: string | null;
|
|
23
|
+
sourceFile: string | null;
|
|
24
|
+
/** `null` when `data-volume` is not authored (the clip plays at 1). */
|
|
25
|
+
volume: number | null;
|
|
26
|
+
lanes: ClipLane[];
|
|
27
|
+
/** `null` at normal speed: not authored, or 1 (the manifest defaults it to 1). */
|
|
28
|
+
playbackRate: number | null;
|
|
29
|
+
audioGroup: string | null;
|
|
30
|
+
role: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The store holds preview URLs; the agent edits project files, so drop the origin and preview prefix. */
|
|
34
|
+
function projectRelativeSrc(src: string): string {
|
|
35
|
+
return src.replace(/^https?:\/\/[^/]+/, "").replace(/^\/api\/projects\/[^/]+\/preview\//, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function describeClip(element: TimelineElement): ClipFact {
|
|
39
|
+
return {
|
|
40
|
+
id: element.domId ?? element.id,
|
|
41
|
+
label: element.label ?? null,
|
|
42
|
+
kind: element.kind ?? element.tag.toLowerCase(),
|
|
43
|
+
start: element.start,
|
|
44
|
+
duration: element.duration,
|
|
45
|
+
end: element.start + element.duration,
|
|
46
|
+
trackIndex: element.authoredTrack ?? element.track,
|
|
47
|
+
src: element.src ? projectRelativeSrc(element.src) : null,
|
|
48
|
+
sourceFile: element.sourceFile ?? null,
|
|
49
|
+
volume: element.volume ?? null,
|
|
50
|
+
lanes: elementAutomationLanes(element).map((lane) => ({
|
|
51
|
+
target: lane.target,
|
|
52
|
+
points: lane.points.map(({ t, v }) => ({ t, v })),
|
|
53
|
+
})),
|
|
54
|
+
playbackRate: element.playbackRate === 1 ? null : (element.playbackRate ?? null),
|
|
55
|
+
audioGroup: element.audioGroup ?? null,
|
|
56
|
+
role: element.timelineRole ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const byStart = (a: ClipFact, b: ClipFact) =>
|
|
61
|
+
a.start - b.start || a.trackIndex - b.trackIndex;
|
|
62
|
+
|
|
63
|
+
export function describeClips(elements: readonly TimelineElement[]): ClipFact[] {
|
|
64
|
+
return elements.map(describeClip).sort(byStart);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const num = (n: number) => String(roundTo3(n));
|
|
68
|
+
|
|
69
|
+
function formatClipLine(clip: ClipFact): string {
|
|
70
|
+
const parts = [
|
|
71
|
+
`${clip.kind} "${clip.id}"`,
|
|
72
|
+
clip.src && `src=${clip.src}`,
|
|
73
|
+
`start=${num(clip.start)}`,
|
|
74
|
+
`duration=${num(clip.duration)}`,
|
|
75
|
+
`end=${num(clip.end)}`,
|
|
76
|
+
`track=${clip.trackIndex}`,
|
|
77
|
+
clip.volume !== null && `volume=${num(clip.volume)}`,
|
|
78
|
+
clip.playbackRate !== null && `rate=${num(clip.playbackRate)}`,
|
|
79
|
+
clip.audioGroup && `group=${clip.audioGroup}`,
|
|
80
|
+
clip.role && `role=${clip.role}`,
|
|
81
|
+
clip.sourceFile && `file=${clip.sourceFile}`,
|
|
82
|
+
...clip.lanes.map(
|
|
83
|
+
(lane) =>
|
|
84
|
+
`${lane.target}-lane=[${lane.points.map((p) => `${num(p.t)}:${num(p.v)}`).join(", ")}]`,
|
|
85
|
+
),
|
|
86
|
+
];
|
|
87
|
+
return `- ${parts.filter(Boolean).join(" ")}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const PROMPT_CLIP_CAP = 200;
|
|
91
|
+
|
|
92
|
+
/** The Ask-agent prompt's Timeline block; empty when the timeline has no clips. */
|
|
93
|
+
export function formatTimelineBlock(elements: readonly TimelineElement[]): string {
|
|
94
|
+
const clips = describeClips(elements);
|
|
95
|
+
if (clips.length === 0) return "";
|
|
96
|
+
const shown = clips.slice(0, PROMPT_CLIP_CAP).map(formatClipLine);
|
|
97
|
+
const more = clips.length - shown.length;
|
|
98
|
+
return [
|
|
99
|
+
"Timeline (composition seconds; lane points are seconds from the clip start):",
|
|
100
|
+
...shown,
|
|
101
|
+
...(more > 0 ? [`(${more} more clips not listed)`] : []),
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -392,6 +355,14 @@ export function deriveTimelineStoreKey(params: {
|
|
|
392
355
|
return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
|
|
393
356
|
}
|
|
394
357
|
|
|
358
|
+
/**
|
|
359
|
+
* {@link deriveTimelineStoreKey} for a caller that already has a DOM id in
|
|
360
|
+
* hand (e.g. one it just minted), so the undefined branch never applies.
|
|
361
|
+
*/
|
|
362
|
+
export function deriveTimelineStoreKeyForDomId(domId: string, sourceFile?: string): string {
|
|
363
|
+
return deriveTimelineStoreKey({ domId, sourceFile })!;
|
|
364
|
+
}
|
|
365
|
+
|
|
395
366
|
// ---------------------------------------------------------------------------
|
|
396
367
|
// DOM node querying
|
|
397
368
|
// ---------------------------------------------------------------------------
|
|
@@ -467,17 +438,3 @@ export function createTimelineDomNodeResolver(doc: Document) {
|
|
|
467
438
|
return node;
|
|
468
439
|
};
|
|
469
440
|
}
|
|
470
|
-
|
|
471
|
-
// ---------------------------------------------------------------------------
|
|
472
|
-
// Implicit layer detection
|
|
473
|
-
// ---------------------------------------------------------------------------
|
|
474
|
-
|
|
475
|
-
export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
|
|
476
|
-
if (!isHtmlElement(el)) return false;
|
|
477
|
-
if (isTimelineIgnoredElement(el)) return false;
|
|
478
|
-
if (el.parentElement !== root) return false;
|
|
479
|
-
const tagName = el.tagName.toLowerCase();
|
|
480
|
-
if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
|
|
481
|
-
if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
|
|
482
|
-
return Boolean(getTimelineElementSelector(el));
|
|
483
|
-
}
|
|
@@ -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(),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Gates `timelineReady` on the composition's declared readiness inputs
|
|
2
|
-
* (media,
|
|
3
|
-
* is module-scope, not store state: it
|
|
4
|
-
* something a component reads, so
|
|
2
|
+
* (media, compute, and the paint-and-idle default) instead of just a known
|
|
3
|
+
* duration. The generation counter is module-scope, not store state: it
|
|
4
|
+
* guards an in-flight settlement, not something a component reads, so
|
|
5
|
+
* bumping it shouldn't trigger a render. */
|
|
5
6
|
import type { StoreApi } from "zustand";
|
|
6
7
|
import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
|
|
7
8
|
|
|
@@ -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
|
|
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.
|