@hyperframes/studio 0.5.5 → 0.6.0-alpha.1
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-Cd8vYWxP.js +198 -0
- package/dist/assets/index-D04_ZoMm.js +107 -0
- package/dist/assets/index-UWFaHilT.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2621 -170
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +67 -0
- package/src/components/editor/PropertyPanel.tsx +2891 -207
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +872 -0
- package/src/components/editor/domEditing.ts +993 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +120 -0
- package/src/components/editor/manualEditingAvailability.ts +60 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1397 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.tsx +27 -4
- package/src/components/nle/NLEPreview.tsx +50 -5
- package/src/components/renders/RenderQueue.tsx +13 -62
- package/src/components/renders/useRenderQueue.ts +6 -30
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +140 -125
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -2
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +103 -21
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-960mgQMI.js +0 -93
|
@@ -228,12 +228,21 @@ export function getTimelineEditCapabilities(input: {
|
|
|
228
228
|
playbackStart?: number;
|
|
229
229
|
playbackStartAttr?: "media-start" | "playback-start";
|
|
230
230
|
sourceDuration?: number;
|
|
231
|
+
timingSource?: "authored" | "implicit";
|
|
231
232
|
}): TimelineEditCapabilities {
|
|
233
|
+
if (input.timingSource === "implicit") {
|
|
234
|
+
return {
|
|
235
|
+
canMove: false,
|
|
236
|
+
canTrimStart: false,
|
|
237
|
+
canTrimEnd: false,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
232
241
|
const canPatch = hasPatchableTimelineTarget(input);
|
|
233
242
|
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
|
|
234
243
|
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
|
235
244
|
return {
|
|
236
|
-
canMove: canPatch && hasDeterministicWindow,
|
|
245
|
+
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
|
237
246
|
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
|
|
238
247
|
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
|
|
239
248
|
};
|
|
@@ -273,7 +282,6 @@ export function buildClipRangeSelection(
|
|
|
273
282
|
anchorY: anchor.anchorY,
|
|
274
283
|
};
|
|
275
284
|
}
|
|
276
|
-
|
|
277
285
|
export function buildTimelineAgentPrompt({
|
|
278
286
|
rangeStart,
|
|
279
287
|
rangeEnd,
|
|
@@ -347,7 +355,6 @@ export function buildTimelineElementAgentPrompt(element: {
|
|
|
347
355
|
|
|
348
356
|
return lines.join("\n");
|
|
349
357
|
}
|
|
350
|
-
|
|
351
358
|
export function formatTimelineAttributeNumber(value: number): string {
|
|
352
359
|
return Number(roundToCentiseconds(value).toFixed(2)).toString();
|
|
353
360
|
}
|
|
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { Window } from "happy-dom";
|
|
3
3
|
import {
|
|
4
4
|
buildStandaloneRootTimelineElement,
|
|
5
|
+
createStaticSeekPlaybackAdapter,
|
|
5
6
|
createTimelineElementFromManifestClip,
|
|
6
7
|
findTimelineDomNodeForClip,
|
|
7
8
|
getTimelineElementSelector,
|
|
8
9
|
parseTimelineFromDOM,
|
|
10
|
+
readTimelineDurationFromDocument,
|
|
9
11
|
type ClipManifestClip,
|
|
10
12
|
mergeTimelineElementsPreservingDowngrades,
|
|
11
13
|
resolveStandaloneRootCompositionSrc,
|
|
@@ -13,6 +15,30 @@ import {
|
|
|
13
15
|
shouldIgnorePlaybackShortcutTarget,
|
|
14
16
|
} from "./useTimelinePlayer";
|
|
15
17
|
|
|
18
|
+
function createDocument(markup: string): Document {
|
|
19
|
+
const window = new Window();
|
|
20
|
+
Object.assign(window, { SyntaxError });
|
|
21
|
+
window.document.body.innerHTML = markup;
|
|
22
|
+
return window.document;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
26
|
+
return {
|
|
27
|
+
id: null,
|
|
28
|
+
label: "",
|
|
29
|
+
start: 0,
|
|
30
|
+
duration: 4,
|
|
31
|
+
track: 0,
|
|
32
|
+
kind: "element",
|
|
33
|
+
tagName: "div",
|
|
34
|
+
compositionId: null,
|
|
35
|
+
parentCompositionId: null,
|
|
36
|
+
compositionSrc: null,
|
|
37
|
+
assetUrl: null,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
function mockTargetMatching(selectorNeedle: string): EventTarget {
|
|
17
43
|
return {
|
|
18
44
|
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
|
|
@@ -33,29 +59,102 @@ function mockKeyboardEvent(
|
|
|
33
59
|
};
|
|
34
60
|
}
|
|
35
61
|
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
62
|
+
function createManualAnimationClock() {
|
|
63
|
+
let now = 0;
|
|
64
|
+
let nextId = 0;
|
|
65
|
+
const callbacks = new Map<number, FrameRequestCallback>();
|
|
43
66
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
now: () => now,
|
|
68
|
+
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
|
69
|
+
nextId += 1;
|
|
70
|
+
callbacks.set(nextId, callback);
|
|
71
|
+
return nextId;
|
|
72
|
+
},
|
|
73
|
+
cancelAnimationFrame: (id: number) => {
|
|
74
|
+
callbacks.delete(id);
|
|
75
|
+
},
|
|
76
|
+
step: (milliseconds: number) => {
|
|
77
|
+
now += milliseconds;
|
|
78
|
+
const pending = Array.from(callbacks.entries());
|
|
79
|
+
callbacks.clear();
|
|
80
|
+
for (const [, callback] of pending) {
|
|
81
|
+
callback(now);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
scheduledCount: () => callbacks.size,
|
|
56
85
|
};
|
|
57
86
|
}
|
|
58
87
|
|
|
88
|
+
describe("readTimelineDurationFromDocument", () => {
|
|
89
|
+
it("prefers the root composition duration", () => {
|
|
90
|
+
const doc = createDocument(`
|
|
91
|
+
<div data-composition-id="main" data-duration="3">
|
|
92
|
+
<section data-start="0" data-duration="8"></section>
|
|
93
|
+
</div>
|
|
94
|
+
`);
|
|
95
|
+
|
|
96
|
+
expect(readTimelineDurationFromDocument(doc)).toBe(3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("falls back to the maximum child end time", () => {
|
|
100
|
+
const doc = createDocument(`
|
|
101
|
+
<div data-composition-id="main">
|
|
102
|
+
<section data-start="1" data-duration="2"></section>
|
|
103
|
+
<section data-start="4" data-duration="1.5"></section>
|
|
104
|
+
</div>
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
expect(readTimelineDurationFromDocument(doc)).toBe(5.5);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("createStaticSeekPlaybackAdapter", () => {
|
|
112
|
+
it("drives renderSeek while playing a duration-only composition", () => {
|
|
113
|
+
const clock = createManualAnimationClock();
|
|
114
|
+
const renderedTimes: number[] = [];
|
|
115
|
+
const adapter = createStaticSeekPlaybackAdapter(
|
|
116
|
+
{
|
|
117
|
+
getTime: () => 0,
|
|
118
|
+
renderSeek: (time: number) => {
|
|
119
|
+
renderedTimes.push(time);
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
3,
|
|
123
|
+
clock,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
adapter.seek(1);
|
|
127
|
+
adapter.play();
|
|
128
|
+
clock.step(500);
|
|
129
|
+
clock.step(2_000);
|
|
130
|
+
|
|
131
|
+
expect(renderedTimes).toEqual([1, 1.5, 3]);
|
|
132
|
+
expect(adapter.getTime()).toBe(3);
|
|
133
|
+
expect(adapter.isPlaying()).toBe(false);
|
|
134
|
+
expect(clock.scheduledCount()).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("clamps explicit seeks to the fallback duration", () => {
|
|
138
|
+
const clock = createManualAnimationClock();
|
|
139
|
+
const renderedTimes: number[] = [];
|
|
140
|
+
const adapter = createStaticSeekPlaybackAdapter(
|
|
141
|
+
{
|
|
142
|
+
getTime: () => 0,
|
|
143
|
+
renderSeek: (time: number) => {
|
|
144
|
+
renderedTimes.push(time);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
2,
|
|
148
|
+
clock,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
adapter.seek(9);
|
|
152
|
+
|
|
153
|
+
expect(renderedTimes).toEqual([2]);
|
|
154
|
+
expect(adapter.getTime()).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
59
158
|
describe("buildStandaloneRootTimelineElement", () => {
|
|
60
159
|
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
|
61
160
|
expect(
|
|
@@ -150,6 +249,36 @@ describe("findTimelineDomNodeForClip", () => {
|
|
|
150
249
|
});
|
|
151
250
|
|
|
152
251
|
describe("anonymous timeline identity", () => {
|
|
252
|
+
it("adds root-level untimed DOM layers as implicit full-duration layers", () => {
|
|
253
|
+
const doc = createDocument(`
|
|
254
|
+
<div data-composition-id="compare" data-start="0" data-duration="18">
|
|
255
|
+
<link rel="stylesheet" href="styles.css" />
|
|
256
|
+
<div class="scene-shell">
|
|
257
|
+
<div class="topline">Title</div>
|
|
258
|
+
</div>
|
|
259
|
+
<video id="main-video" class="clip main-video" data-start="0" data-duration="18" data-track-index="1"></video>
|
|
260
|
+
<script></script>
|
|
261
|
+
</div>
|
|
262
|
+
`);
|
|
263
|
+
|
|
264
|
+
const elements = parseTimelineFromDOM(doc, 18);
|
|
265
|
+
|
|
266
|
+
expect(elements).toEqual(
|
|
267
|
+
expect.arrayContaining([
|
|
268
|
+
expect.objectContaining({
|
|
269
|
+
duration: 18,
|
|
270
|
+
label: "Scene Shell",
|
|
271
|
+
selector: ".scene-shell",
|
|
272
|
+
start: 0,
|
|
273
|
+
tag: "div",
|
|
274
|
+
timingSource: "implicit",
|
|
275
|
+
}),
|
|
276
|
+
]),
|
|
277
|
+
);
|
|
278
|
+
expect(elements.find((element) => element.tag === "link")).toBeUndefined();
|
|
279
|
+
expect(elements.find((element) => element.tag === "script")).toBeUndefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
153
282
|
it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
|
|
154
283
|
const doc = createDocument(`
|
|
155
284
|
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
@@ -4,7 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
4
4
|
import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
5
|
import { useCaptionStore } from "../../captions/store";
|
|
6
6
|
|
|
7
|
-
interface PlaybackAdapter {
|
|
7
|
+
export interface PlaybackAdapter {
|
|
8
8
|
play: () => void;
|
|
9
9
|
pause: () => void;
|
|
10
10
|
seek: (time: number) => void;
|
|
@@ -13,6 +13,16 @@ interface PlaybackAdapter {
|
|
|
13
13
|
isPlaying: () => boolean;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
type RuntimePlaybackAdapter = PlaybackAdapter & {
|
|
17
|
+
renderSeek?: (time: number) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface StaticSeekPlaybackClock {
|
|
21
|
+
now: () => number;
|
|
22
|
+
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
|
23
|
+
cancelAnimationFrame: (handle: number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
interface TimelineLike {
|
|
17
27
|
play: () => void;
|
|
18
28
|
pause: () => void;
|
|
@@ -22,7 +32,7 @@ interface TimelineLike {
|
|
|
22
32
|
isActive: () => boolean;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
interface ClipManifestClip {
|
|
35
|
+
export interface ClipManifestClip {
|
|
26
36
|
id: string | null;
|
|
27
37
|
label: string;
|
|
28
38
|
start: number;
|
|
@@ -43,12 +53,133 @@ interface ClipManifest {
|
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
type IframeWindow = Window & {
|
|
46
|
-
__player?:
|
|
56
|
+
__player?: RuntimePlaybackAdapter;
|
|
47
57
|
__timeline?: TimelineLike;
|
|
48
58
|
__timelines?: Record<string, TimelineLike>;
|
|
49
59
|
__clipManifest?: ClipManifest;
|
|
50
60
|
};
|
|
51
61
|
|
|
62
|
+
function isFinitePositive(value: number): boolean {
|
|
63
|
+
return Number.isFinite(value) && value > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clampTime(time: number, duration: number): number {
|
|
67
|
+
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
|
|
68
|
+
const safeTime = Math.max(0, Number.isFinite(time) ? time : 0);
|
|
69
|
+
return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readDurationAttribute(el: Element | null | undefined): number {
|
|
73
|
+
if (!el) return 0;
|
|
74
|
+
const duration =
|
|
75
|
+
Number.parseFloat(el.getAttribute("data-duration") ?? "") ||
|
|
76
|
+
Number.parseFloat(el.getAttribute("data-hf-authored-duration") ?? "");
|
|
77
|
+
return isFinitePositive(duration) ? duration : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
|
|
81
|
+
if (!doc) return 0;
|
|
82
|
+
const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
|
|
83
|
+
if (rootDuration > 0) return rootDuration;
|
|
84
|
+
|
|
85
|
+
let maxEnd = 0;
|
|
86
|
+
for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
|
|
87
|
+
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
|
|
88
|
+
const duration = readDurationAttribute(node);
|
|
89
|
+
if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
|
|
90
|
+
maxEnd = Math.max(maxEnd, start + duration);
|
|
91
|
+
}
|
|
92
|
+
return maxEnd;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number {
|
|
96
|
+
if (!adapter) return 0;
|
|
97
|
+
try {
|
|
98
|
+
const duration = Number(adapter.getDuration());
|
|
99
|
+
return isFinitePositive(duration) ? duration : 0;
|
|
100
|
+
} catch {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock {
|
|
106
|
+
return {
|
|
107
|
+
now: () => win.performance.now(),
|
|
108
|
+
requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
|
|
109
|
+
cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createStaticSeekPlaybackAdapter(
|
|
114
|
+
player: Pick<RuntimePlaybackAdapter, "getTime"> &
|
|
115
|
+
Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>,
|
|
116
|
+
duration: number,
|
|
117
|
+
clock: StaticSeekPlaybackClock,
|
|
118
|
+
getPlaybackRate: () => number = () => 1,
|
|
119
|
+
): PlaybackAdapter {
|
|
120
|
+
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
|
|
121
|
+
let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration);
|
|
122
|
+
let playing = false;
|
|
123
|
+
let rafId = 0;
|
|
124
|
+
let playStartTime = currentTime;
|
|
125
|
+
let playStartNow = clock.now();
|
|
126
|
+
|
|
127
|
+
const renderSeek = (time: number) => {
|
|
128
|
+
currentTime = clampTime(time, safeDuration);
|
|
129
|
+
if (typeof player.renderSeek === "function") {
|
|
130
|
+
player.renderSeek(currentTime);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
player.seek?.(currentTime);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const stopTicker = () => {
|
|
137
|
+
if (rafId) {
|
|
138
|
+
clock.cancelAnimationFrame(rafId);
|
|
139
|
+
rafId = 0;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const tick: FrameRequestCallback = (now) => {
|
|
144
|
+
if (!playing) return;
|
|
145
|
+
const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1);
|
|
146
|
+
const elapsed = ((now - playStartNow) / 1000) * playbackRate;
|
|
147
|
+
renderSeek(playStartTime + elapsed);
|
|
148
|
+
if (currentTime >= safeDuration) {
|
|
149
|
+
playing = false;
|
|
150
|
+
rafId = 0;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
rafId = clock.requestAnimationFrame(tick);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
play: () => {
|
|
158
|
+
if (playing || safeDuration <= 0) return;
|
|
159
|
+
if (currentTime >= safeDuration) renderSeek(0);
|
|
160
|
+
playing = true;
|
|
161
|
+
playStartTime = currentTime;
|
|
162
|
+
playStartNow = clock.now();
|
|
163
|
+
stopTicker();
|
|
164
|
+
rafId = clock.requestAnimationFrame(tick);
|
|
165
|
+
},
|
|
166
|
+
pause: () => {
|
|
167
|
+
playing = false;
|
|
168
|
+
stopTicker();
|
|
169
|
+
},
|
|
170
|
+
seek: (time) => {
|
|
171
|
+
renderSeek(time);
|
|
172
|
+
if (playing) {
|
|
173
|
+
playStartTime = currentTime;
|
|
174
|
+
playStartNow = clock.now();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
getTime: () => currentTime,
|
|
178
|
+
getDuration: () => safeDuration,
|
|
179
|
+
isPlaying: () => playing,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
52
183
|
function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
53
184
|
return {
|
|
54
185
|
play: () => tl.play(),
|
|
@@ -183,6 +314,100 @@ function getTimelineElementDisplayLabel(input: {
|
|
|
183
314
|
return tag ? `${tag} clip` : "Timeline clip";
|
|
184
315
|
}
|
|
185
316
|
|
|
317
|
+
const IMPLICIT_TIMELINE_LAYER_SKIP_TAGS = new Set([
|
|
318
|
+
"base",
|
|
319
|
+
"link",
|
|
320
|
+
"meta",
|
|
321
|
+
"noscript",
|
|
322
|
+
"script",
|
|
323
|
+
"style",
|
|
324
|
+
"template",
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
function humanizeTimelineIdentifier(value: string): string {
|
|
328
|
+
return value
|
|
329
|
+
.trim()
|
|
330
|
+
.replace(/[_-]+/g, " ")
|
|
331
|
+
.replace(/\s+/g, " ")
|
|
332
|
+
.replace(/\b\w/g, (match) => match.toUpperCase());
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getImplicitTimelineLayerLabel(el: HTMLElement): string {
|
|
336
|
+
const explicitLabel =
|
|
337
|
+
el.getAttribute("data-timeline-label") ??
|
|
338
|
+
el.getAttribute("data-label") ??
|
|
339
|
+
el.getAttribute("aria-label");
|
|
340
|
+
if (explicitLabel?.trim()) return explicitLabel.trim();
|
|
341
|
+
if (el.id.trim()) return humanizeTimelineIdentifier(el.id);
|
|
342
|
+
const classes = el.className.split(/\s+/).filter(Boolean);
|
|
343
|
+
const className = classes.find((value) => value !== "clip") ?? classes[0];
|
|
344
|
+
if (className) return humanizeTimelineIdentifier(className);
|
|
345
|
+
return getTimelineElementDisplayLabel({ tag: el.tagName });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement {
|
|
349
|
+
if (!isHtmlElement(el)) return false;
|
|
350
|
+
if (el.parentElement !== root) return false;
|
|
351
|
+
const tagName = el.tagName.toLowerCase();
|
|
352
|
+
if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false;
|
|
353
|
+
if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false;
|
|
354
|
+
return Boolean(getTimelineElementSelector(el));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function createImplicitTimelineLayersFromDOM(
|
|
358
|
+
doc: Document,
|
|
359
|
+
rootDuration: number,
|
|
360
|
+
existingElements: readonly TimelineElement[] = [],
|
|
361
|
+
): TimelineElement[] {
|
|
362
|
+
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
|
|
363
|
+
const rootComp = doc.querySelector("[data-composition-id]");
|
|
364
|
+
if (!rootComp) return [];
|
|
365
|
+
|
|
366
|
+
const existingKeys = new Set(existingElements.map(getTimelineElementIdentity));
|
|
367
|
+
const maxTrack = existingElements.reduce(
|
|
368
|
+
(max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0),
|
|
369
|
+
-1,
|
|
370
|
+
);
|
|
371
|
+
const layers: TimelineElement[] = [];
|
|
372
|
+
|
|
373
|
+
for (const child of Array.from(rootComp.children)) {
|
|
374
|
+
if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue;
|
|
375
|
+
|
|
376
|
+
const selector = getTimelineElementSelector(child);
|
|
377
|
+
if (!selector) continue;
|
|
378
|
+
const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector);
|
|
379
|
+
const sourceFile = getTimelineElementSourceFile(child);
|
|
380
|
+
const label = getImplicitTimelineLayerLabel(child);
|
|
381
|
+
const identity = buildTimelineElementIdentity({
|
|
382
|
+
preferredId: child.id || null,
|
|
383
|
+
label,
|
|
384
|
+
fallbackIndex: existingElements.length + layers.length,
|
|
385
|
+
domId: child.id || undefined,
|
|
386
|
+
selector,
|
|
387
|
+
selectorIndex,
|
|
388
|
+
sourceFile,
|
|
389
|
+
});
|
|
390
|
+
if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
|
|
391
|
+
|
|
392
|
+
layers.push({
|
|
393
|
+
domId: child.id || undefined,
|
|
394
|
+
duration: rootDuration,
|
|
395
|
+
id: identity.id,
|
|
396
|
+
key: identity.key,
|
|
397
|
+
label,
|
|
398
|
+
selector,
|
|
399
|
+
selectorIndex,
|
|
400
|
+
sourceFile,
|
|
401
|
+
start: 0,
|
|
402
|
+
tag: child.tagName.toLowerCase(),
|
|
403
|
+
timingSource: "implicit",
|
|
404
|
+
track: maxTrack + 1 + layers.length,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return layers;
|
|
409
|
+
}
|
|
410
|
+
|
|
186
411
|
/**
|
|
187
412
|
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
188
413
|
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
@@ -244,6 +469,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
244
469
|
selector,
|
|
245
470
|
selectorIndex,
|
|
246
471
|
sourceFile,
|
|
472
|
+
timingSource: "authored",
|
|
247
473
|
};
|
|
248
474
|
|
|
249
475
|
const mediaEl = resolveMediaElement(el);
|
|
@@ -275,7 +501,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
275
501
|
els.push(entry);
|
|
276
502
|
});
|
|
277
503
|
|
|
278
|
-
return els;
|
|
504
|
+
return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)];
|
|
279
505
|
}
|
|
280
506
|
|
|
281
507
|
function isHtmlElement(el: Element): el is HTMLElement {
|
|
@@ -335,7 +561,6 @@ function buildTimelineElementKey(params: {
|
|
|
335
561
|
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
|
|
336
562
|
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
337
563
|
}
|
|
338
|
-
|
|
339
564
|
function buildTimelineElementIdentity(params: {
|
|
340
565
|
preferredId?: string | null;
|
|
341
566
|
label: string;
|
|
@@ -557,7 +782,6 @@ export function buildStandaloneRootTimelineElement(params: {
|
|
|
557
782
|
sourceFile: compositionSrc,
|
|
558
783
|
};
|
|
559
784
|
}
|
|
560
|
-
|
|
561
785
|
function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
562
786
|
if (doc.documentElement) {
|
|
563
787
|
doc.documentElement.style.overflow = "hidden";
|
|
@@ -682,6 +906,11 @@ export function useTimelinePlayer() {
|
|
|
682
906
|
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
|
|
683
907
|
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
684
908
|
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
909
|
+
const staticSeekAdapterRef = useRef<{
|
|
910
|
+
player: RuntimePlaybackAdapter;
|
|
911
|
+
duration: number;
|
|
912
|
+
adapter: PlaybackAdapter;
|
|
913
|
+
} | null>(null);
|
|
685
914
|
|
|
686
915
|
// ZERO store subscriptions — this hook never causes re-renders.
|
|
687
916
|
// All reads use getState() (point-in-time), all writes use the stable setters.
|
|
@@ -710,13 +939,18 @@ export function useTimelinePlayer() {
|
|
|
710
939
|
try {
|
|
711
940
|
const iframe = iframeRef.current;
|
|
712
941
|
const win = iframe?.contentWindow as IframeWindow | null;
|
|
713
|
-
if (!win) return null;
|
|
942
|
+
if (!iframe || !win) return null;
|
|
714
943
|
|
|
715
|
-
|
|
716
|
-
|
|
944
|
+
const playerAdapter =
|
|
945
|
+
win.__player && typeof win.__player.play === "function" ? win.__player : null;
|
|
946
|
+
if (getAdapterDuration(playerAdapter) > 0) {
|
|
947
|
+
return playerAdapter;
|
|
717
948
|
}
|
|
718
949
|
|
|
719
|
-
if (win.__timeline)
|
|
950
|
+
if (win.__timeline) {
|
|
951
|
+
const adapter = wrapTimeline(win.__timeline);
|
|
952
|
+
if (getAdapterDuration(adapter) > 0) return adapter;
|
|
953
|
+
}
|
|
720
954
|
|
|
721
955
|
if (win.__timelines) {
|
|
722
956
|
const keys = Object.keys(win.__timelines);
|
|
@@ -729,11 +963,40 @@ export function useTimelinePlayer() {
|
|
|
729
963
|
?.querySelector("[data-composition-id]")
|
|
730
964
|
?.getAttribute("data-composition-id");
|
|
731
965
|
const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
|
|
732
|
-
|
|
966
|
+
const adapter = wrapTimeline(win.__timelines[key]);
|
|
967
|
+
if (getAdapterDuration(adapter) > 0) return adapter;
|
|
733
968
|
}
|
|
734
969
|
}
|
|
735
970
|
|
|
736
|
-
|
|
971
|
+
const fallbackDuration = Math.max(
|
|
972
|
+
usePlayerStore.getState().duration,
|
|
973
|
+
readTimelineDurationFromDocument(iframe.contentDocument),
|
|
974
|
+
);
|
|
975
|
+
if (
|
|
976
|
+
playerAdapter &&
|
|
977
|
+
fallbackDuration > 0 &&
|
|
978
|
+
(typeof playerAdapter.renderSeek === "function" || typeof playerAdapter.seek === "function")
|
|
979
|
+
) {
|
|
980
|
+
const cached = staticSeekAdapterRef.current;
|
|
981
|
+
if (cached?.player === playerAdapter && cached.duration === fallbackDuration) {
|
|
982
|
+
return cached.adapter;
|
|
983
|
+
}
|
|
984
|
+
cached?.adapter.pause();
|
|
985
|
+
const adapter = createStaticSeekPlaybackAdapter(
|
|
986
|
+
playerAdapter,
|
|
987
|
+
fallbackDuration,
|
|
988
|
+
getDefaultStaticSeekPlaybackClock(win),
|
|
989
|
+
() => usePlayerStore.getState().playbackRate,
|
|
990
|
+
);
|
|
991
|
+
staticSeekAdapterRef.current = {
|
|
992
|
+
player: playerAdapter,
|
|
993
|
+
duration: fallbackDuration,
|
|
994
|
+
adapter,
|
|
995
|
+
};
|
|
996
|
+
return adapter;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return playerAdapter;
|
|
737
1000
|
} catch (err) {
|
|
738
1001
|
console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err);
|
|
739
1002
|
return null;
|
|
@@ -1066,8 +1329,15 @@ export function useTimelinePlayer() {
|
|
|
1066
1329
|
}))
|
|
1067
1330
|
.filter((element) => element.duration > 0)
|
|
1068
1331
|
: els;
|
|
1069
|
-
|
|
1070
|
-
|
|
1332
|
+
const timelineEls =
|
|
1333
|
+
iframeDoc && effectiveDuration > 0
|
|
1334
|
+
? [
|
|
1335
|
+
...clampedEls,
|
|
1336
|
+
...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
|
|
1337
|
+
]
|
|
1338
|
+
: clampedEls;
|
|
1339
|
+
if (timelineEls.length > 0) {
|
|
1340
|
+
syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
|
|
1071
1341
|
}
|
|
1072
1342
|
},
|
|
1073
1343
|
[syncTimelineElements],
|
|
@@ -1351,6 +1621,9 @@ export function useTimelinePlayer() {
|
|
|
1351
1621
|
setIsPlaying(false);
|
|
1352
1622
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
1353
1623
|
|
|
1624
|
+
const togglePlayRef = useRef(togglePlay);
|
|
1625
|
+
togglePlayRef.current = togglePlay;
|
|
1626
|
+
|
|
1354
1627
|
const refreshPlayer = useCallback(() => {
|
|
1355
1628
|
const iframe = iframeRef.current;
|
|
1356
1629
|
if (!iframe) return;
|
|
@@ -1459,8 +1732,6 @@ export function useTimelinePlayer() {
|
|
|
1459
1732
|
stopRAFLoop();
|
|
1460
1733
|
stopReverseLoop();
|
|
1461
1734
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
1462
|
-
// Don't reset() on cleanup — preserve timeline elements across iframe refreshes
|
|
1463
|
-
// to prevent blink. New data will replace old when the iframe reloads.
|
|
1464
1735
|
};
|
|
1465
1736
|
});
|
|
1466
1737
|
|
|
@@ -23,6 +23,8 @@ export interface TimelineElement {
|
|
|
23
23
|
volume?: number;
|
|
24
24
|
/** Path from data-composition-src — identifies sub-composition elements */
|
|
25
25
|
compositionSrc?: string;
|
|
26
|
+
/** Whether this row came from authored clip timing or Studio's full-duration layer fallback. */
|
|
27
|
+
timingSource?: "authored" | "implicit";
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export type ZoomMode = "fit" | "manual";
|