@hyperframes/studio 0.5.7 → 0.6.0-alpha.10
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/index-14zH9lqh.css +1 -0
- package/dist/assets/index-B-16fRnH.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2965 -186
- 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 +116 -0
- package/src/components/editor/PropertyPanel.tsx +2829 -205
- 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 +1120 -0
- package/src/components/editor/domEditing.ts +1117 -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 +131 -0
- package/src/components/editor/manualEditingAvailability.ts +62 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1409 -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.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +84 -22
- package/src/components/nle/NLEPreview.tsx +56 -5
- package/src/components/renders/RenderQueue.tsx +24 -11
- 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 +194 -179
- 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.test.ts +58 -0
- package/src/player/components/Player.tsx +88 -5
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +147 -40
- 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/timelineDiscovery.ts +1 -1
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-Dcw3BoVw.js +0 -93
|
@@ -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";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Window } from "happy-dom";
|
|
3
|
+
import { copyTextToClipboard } from "./clipboard";
|
|
4
|
+
|
|
5
|
+
function installDocument(execCommand: (command: string) => boolean): void {
|
|
6
|
+
const window = new Window();
|
|
7
|
+
Object.assign(window, { SyntaxError });
|
|
8
|
+
Object.defineProperty(window.document, "execCommand", {
|
|
9
|
+
configurable: true,
|
|
10
|
+
value: execCommand,
|
|
11
|
+
});
|
|
12
|
+
vi.stubGlobal("document", window.document);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function installNavigator(
|
|
16
|
+
writeText: (text: string) => Promise<void>,
|
|
17
|
+
userAgent = "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36",
|
|
18
|
+
): void {
|
|
19
|
+
vi.stubGlobal("navigator", {
|
|
20
|
+
clipboard: { writeText },
|
|
21
|
+
userAgent,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("copyTextToClipboard", () => {
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.unstubAllGlobals();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("uses the synchronous selection copy path first in Safari", async () => {
|
|
31
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
32
|
+
const writeText = vi.fn((_text: string) => Promise.resolve());
|
|
33
|
+
|
|
34
|
+
installDocument(execCommand);
|
|
35
|
+
installNavigator(
|
|
36
|
+
writeText,
|
|
37
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
41
|
+
|
|
42
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
43
|
+
expect(writeText).not.toHaveBeenCalled();
|
|
44
|
+
expect(document.querySelector("textarea")).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("uses navigator.clipboard first outside Safari", async () => {
|
|
48
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
49
|
+
const writeText = vi.fn((_text: string) => Promise.resolve());
|
|
50
|
+
|
|
51
|
+
installDocument(execCommand);
|
|
52
|
+
installNavigator(writeText);
|
|
53
|
+
|
|
54
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
55
|
+
|
|
56
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
57
|
+
expect(execCommand).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to selection copy outside Safari when navigator.clipboard fails", async () => {
|
|
61
|
+
const execCommand = vi.fn((command: string) => command === "copy");
|
|
62
|
+
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
|
|
63
|
+
|
|
64
|
+
installDocument(execCommand);
|
|
65
|
+
installNavigator(writeText);
|
|
66
|
+
|
|
67
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(true);
|
|
68
|
+
|
|
69
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
70
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reports failure when both copy paths fail", async () => {
|
|
74
|
+
const execCommand = vi.fn(() => false);
|
|
75
|
+
const writeText = vi.fn((_text: string) => Promise.reject(new Error("blocked")));
|
|
76
|
+
|
|
77
|
+
installDocument(execCommand);
|
|
78
|
+
installNavigator(
|
|
79
|
+
writeText,
|
|
80
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await expect(copyTextToClipboard("copy me")).resolves.toBe(false);
|
|
84
|
+
|
|
85
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
86
|
+
expect(writeText).toHaveBeenCalledWith("copy me");
|
|
87
|
+
expect(document.querySelector("textarea")).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function copyWithSelection(text: string): boolean {
|
|
2
|
+
if (typeof document === "undefined" || !document.body || !document.execCommand) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const textarea = document.createElement("textarea");
|
|
7
|
+
textarea.value = text;
|
|
8
|
+
textarea.setAttribute("readonly", "true");
|
|
9
|
+
textarea.style.position = "fixed";
|
|
10
|
+
textarea.style.top = "0";
|
|
11
|
+
textarea.style.left = "0";
|
|
12
|
+
textarea.style.width = "1px";
|
|
13
|
+
textarea.style.height = "1px";
|
|
14
|
+
textarea.style.padding = "0";
|
|
15
|
+
textarea.style.border = "0";
|
|
16
|
+
textarea.style.opacity = "0";
|
|
17
|
+
textarea.style.pointerEvents = "none";
|
|
18
|
+
|
|
19
|
+
document.body.appendChild(textarea);
|
|
20
|
+
textarea.focus({ preventScroll: true });
|
|
21
|
+
textarea.select();
|
|
22
|
+
textarea.setSelectionRange(0, text.length);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return document.execCommand("copy");
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
} finally {
|
|
29
|
+
document.body.removeChild(textarea);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function shouldCopyWithSelectionFirst(): boolean {
|
|
34
|
+
if (typeof navigator === "undefined") return false;
|
|
35
|
+
|
|
36
|
+
const userAgent = navigator.userAgent;
|
|
37
|
+
return /Safari/i.test(userAgent) && !/Chrome|Chromium|CriOS|FxiOS|Edg|OPR/i.test(userAgent);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
|
41
|
+
const useSelectionFirst = shouldCopyWithSelectionFirst();
|
|
42
|
+
if (useSelectionFirst && copyWithSelection(text)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
|
47
|
+
if (clipboard?.writeText) {
|
|
48
|
+
try {
|
|
49
|
+
await clipboard.writeText(text);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
// Fall back below when the browser still allows synchronous copy.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return !useSelectionFirst && copyWithSelection(text);
|
|
57
|
+
}
|