@hyperframes/studio 0.5.0-alpha.9 → 0.5.0
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-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1438
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2466
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +5 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +160 -21
- package/src/player/hooks/useTimelinePlayer.ts +206 -93
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -445
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- package/src/utils/clipboard.ts +0 -57
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
-
import {
|
|
4
|
+
import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
5
5
|
import { useCaptionStore } from "../../captions/store";
|
|
6
6
|
|
|
7
7
|
interface PlaybackAdapter {
|
|
@@ -22,7 +22,7 @@ interface TimelineLike {
|
|
|
22
22
|
isActive: () => boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
interface ClipManifestClip {
|
|
26
26
|
id: string | null;
|
|
27
27
|
label: string;
|
|
28
28
|
start: number;
|
|
@@ -64,9 +64,12 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageElement | null {
|
|
67
|
-
|
|
67
|
+
const win = el.ownerDocument.defaultView ?? window;
|
|
68
|
+
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
69
|
+
const ImageElementCtor = win.HTMLImageElement ?? globalThis.HTMLImageElement;
|
|
70
|
+
if (el instanceof MediaElementCtor || el instanceof ImageElementCtor) return el;
|
|
68
71
|
const candidate = el.querySelector("video, audio, img");
|
|
69
|
-
return candidate instanceof
|
|
72
|
+
return candidate instanceof MediaElementCtor || candidate instanceof ImageElementCtor
|
|
70
73
|
? candidate
|
|
71
74
|
: null;
|
|
72
75
|
}
|
|
@@ -92,7 +95,9 @@ function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): voi
|
|
|
92
95
|
const src = mediaEl.getAttribute("src");
|
|
93
96
|
if (src) entry.src = src;
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
const win = mediaEl.ownerDocument.defaultView ?? window;
|
|
99
|
+
const MediaElementCtor = win.HTMLMediaElement ?? globalThis.HTMLMediaElement;
|
|
100
|
+
if (typeof MediaElementCtor === "undefined" || !(mediaEl instanceof MediaElementCtor)) return;
|
|
96
101
|
|
|
97
102
|
const sourceDurationAttr =
|
|
98
103
|
el.getAttribute("data-source-duration") ?? mediaEl.getAttribute("data-source-duration");
|
|
@@ -165,11 +170,24 @@ export function shouldIgnorePlaybackShortcutEvent(
|
|
|
165
170
|
);
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
function getTimelineElementDisplayLabel(input: {
|
|
174
|
+
id?: string | null;
|
|
175
|
+
label?: string | null;
|
|
176
|
+
tag?: string | null;
|
|
177
|
+
}): string {
|
|
178
|
+
const label = input.label?.trim();
|
|
179
|
+
if (label) return label;
|
|
180
|
+
const id = input.id?.trim();
|
|
181
|
+
if (id) return id;
|
|
182
|
+
const tag = input.tag?.trim().toLowerCase();
|
|
183
|
+
return tag ? `${tag} clip` : "Timeline clip";
|
|
184
|
+
}
|
|
185
|
+
|
|
168
186
|
/**
|
|
169
187
|
* Parse [data-start] elements from a Document into TimelineElement[].
|
|
170
188
|
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
|
171
189
|
*/
|
|
172
|
-
function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
|
190
|
+
export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
|
173
191
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
174
192
|
const nodes = doc.querySelectorAll("[data-start]");
|
|
175
193
|
const els: TimelineElement[] = [];
|
|
@@ -200,17 +218,24 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
|
|
200
218
|
const selector = getTimelineElementSelector(el);
|
|
201
219
|
const sourceFile = getTimelineElementSourceFile(el);
|
|
202
220
|
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
203
|
-
const
|
|
221
|
+
const label = getTimelineElementDisplayLabel({
|
|
222
|
+
id: el.id || compId || null,
|
|
223
|
+
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
224
|
+
tag: tagLower,
|
|
225
|
+
});
|
|
226
|
+
const identity = buildTimelineElementIdentity({
|
|
227
|
+
preferredId: el.id || compId || null,
|
|
228
|
+
label,
|
|
229
|
+
fallbackIndex: els.length,
|
|
230
|
+
domId: el.id || undefined,
|
|
231
|
+
selector,
|
|
232
|
+
selectorIndex,
|
|
233
|
+
sourceFile,
|
|
234
|
+
});
|
|
204
235
|
const entry: TimelineElement = {
|
|
205
|
-
id,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
fallbackIndex: els.length,
|
|
209
|
-
domId: el.id || undefined,
|
|
210
|
-
selector,
|
|
211
|
-
selectorIndex,
|
|
212
|
-
sourceFile,
|
|
213
|
-
}),
|
|
236
|
+
id: identity.id,
|
|
237
|
+
label,
|
|
238
|
+
key: identity.key,
|
|
214
239
|
tag: tagLower,
|
|
215
240
|
start,
|
|
216
241
|
duration: dur,
|
|
@@ -311,6 +336,40 @@ function buildTimelineElementKey(params: {
|
|
|
311
336
|
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
|
312
337
|
}
|
|
313
338
|
|
|
339
|
+
function buildTimelineElementIdentity(params: {
|
|
340
|
+
preferredId?: string | null;
|
|
341
|
+
label: string;
|
|
342
|
+
fallbackIndex: number;
|
|
343
|
+
domId?: string;
|
|
344
|
+
selector?: string;
|
|
345
|
+
selectorIndex?: number;
|
|
346
|
+
sourceFile?: string;
|
|
347
|
+
}): { id: string; key: string } {
|
|
348
|
+
const id =
|
|
349
|
+
params.preferredId?.trim() ||
|
|
350
|
+
buildTimelineElementKey({
|
|
351
|
+
id: params.label,
|
|
352
|
+
fallbackIndex: params.fallbackIndex,
|
|
353
|
+
domId: params.domId,
|
|
354
|
+
selector: params.selector,
|
|
355
|
+
selectorIndex: params.selectorIndex,
|
|
356
|
+
sourceFile: params.sourceFile,
|
|
357
|
+
});
|
|
358
|
+
const key = buildTimelineElementKey({
|
|
359
|
+
id,
|
|
360
|
+
fallbackIndex: params.fallbackIndex,
|
|
361
|
+
domId: params.domId,
|
|
362
|
+
selector: params.selector,
|
|
363
|
+
selectorIndex: params.selectorIndex,
|
|
364
|
+
sourceFile: params.sourceFile,
|
|
365
|
+
});
|
|
366
|
+
return { id, key };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getTimelineElementIdentity(element: TimelineElement): string {
|
|
370
|
+
return element.key ?? element.id;
|
|
371
|
+
}
|
|
372
|
+
|
|
314
373
|
function getTimelineDomNodes(doc: Document): Element[] {
|
|
315
374
|
const rootComp = doc.querySelector("[data-composition-id]");
|
|
316
375
|
return Array.from(doc.querySelectorAll("[data-start]")).filter((node) => node !== rootComp);
|
|
@@ -352,6 +411,103 @@ export function findTimelineDomNodeForClip(
|
|
|
352
411
|
return candidates[fallbackIndex] ?? null;
|
|
353
412
|
}
|
|
354
413
|
|
|
414
|
+
export function createTimelineElementFromManifestClip(params: {
|
|
415
|
+
clip: ClipManifestClip;
|
|
416
|
+
fallbackIndex: number;
|
|
417
|
+
doc?: Document | null;
|
|
418
|
+
hostEl?: Element | null;
|
|
419
|
+
}): TimelineElement {
|
|
420
|
+
const { clip, fallbackIndex, doc } = params;
|
|
421
|
+
let hostEl = params.hostEl ?? null;
|
|
422
|
+
const label = getTimelineElementDisplayLabel({
|
|
423
|
+
id: clip.id,
|
|
424
|
+
label: clip.label,
|
|
425
|
+
tag: clip.tagName || clip.kind,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
let domId: string | undefined;
|
|
429
|
+
let selector: string | undefined;
|
|
430
|
+
let selectorIndex: number | undefined;
|
|
431
|
+
let sourceFile: string | undefined;
|
|
432
|
+
|
|
433
|
+
if (hostEl) {
|
|
434
|
+
domId = hostEl.id || undefined;
|
|
435
|
+
selector = getTimelineElementSelector(hostEl);
|
|
436
|
+
selectorIndex =
|
|
437
|
+
doc && selector ? getTimelineElementSelectorIndex(doc, hostEl, selector) : undefined;
|
|
438
|
+
sourceFile = getTimelineElementSourceFile(hostEl);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const identity = buildTimelineElementIdentity({
|
|
442
|
+
preferredId: clip.id,
|
|
443
|
+
label,
|
|
444
|
+
fallbackIndex,
|
|
445
|
+
domId,
|
|
446
|
+
selector,
|
|
447
|
+
selectorIndex,
|
|
448
|
+
sourceFile,
|
|
449
|
+
});
|
|
450
|
+
const entry: TimelineElement = {
|
|
451
|
+
id: identity.id,
|
|
452
|
+
label,
|
|
453
|
+
key: identity.key,
|
|
454
|
+
tag: clip.tagName || clip.kind,
|
|
455
|
+
start: clip.start,
|
|
456
|
+
duration: clip.duration,
|
|
457
|
+
track: clip.track,
|
|
458
|
+
domId,
|
|
459
|
+
selector,
|
|
460
|
+
selectorIndex,
|
|
461
|
+
sourceFile,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
if (hostEl) {
|
|
465
|
+
applyMediaMetadataFromElement(entry, hostEl);
|
|
466
|
+
}
|
|
467
|
+
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
468
|
+
if (clip.kind === "composition" && clip.compositionId) {
|
|
469
|
+
let resolvedSrc = clip.compositionSrc;
|
|
470
|
+
if (!resolvedSrc) {
|
|
471
|
+
hostEl = doc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
472
|
+
resolvedSrc =
|
|
473
|
+
hostEl?.getAttribute("data-composition-src") ??
|
|
474
|
+
hostEl?.getAttribute("data-composition-file") ??
|
|
475
|
+
null;
|
|
476
|
+
}
|
|
477
|
+
if (resolvedSrc) {
|
|
478
|
+
entry.compositionSrc = resolvedSrc;
|
|
479
|
+
} else if (hostEl) {
|
|
480
|
+
const innerVideo = hostEl.querySelector("video[src]");
|
|
481
|
+
if (innerVideo) {
|
|
482
|
+
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
483
|
+
entry.tag = "video";
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (hostEl) {
|
|
487
|
+
entry.domId = hostEl.id || undefined;
|
|
488
|
+
entry.selector = getTimelineElementSelector(hostEl);
|
|
489
|
+
entry.selectorIndex =
|
|
490
|
+
doc && entry.selector
|
|
491
|
+
? getTimelineElementSelectorIndex(doc, hostEl, entry.selector)
|
|
492
|
+
: undefined;
|
|
493
|
+
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
494
|
+
const nextIdentity = buildTimelineElementIdentity({
|
|
495
|
+
preferredId: clip.id,
|
|
496
|
+
label,
|
|
497
|
+
fallbackIndex,
|
|
498
|
+
domId: entry.domId,
|
|
499
|
+
selector: entry.selector,
|
|
500
|
+
selectorIndex: entry.selectorIndex,
|
|
501
|
+
sourceFile: entry.sourceFile,
|
|
502
|
+
});
|
|
503
|
+
entry.id = nextIdentity.id;
|
|
504
|
+
entry.key = nextIdentity.key;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return entry;
|
|
509
|
+
}
|
|
510
|
+
|
|
355
511
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
356
512
|
return (
|
|
357
513
|
doc.getElementById(id) ??
|
|
@@ -380,6 +536,10 @@ export function buildStandaloneRootTimelineElement(params: {
|
|
|
380
536
|
|
|
381
537
|
return {
|
|
382
538
|
id: params.compositionId,
|
|
539
|
+
label: getTimelineElementDisplayLabel({
|
|
540
|
+
id: params.compositionId,
|
|
541
|
+
tag: params.tagName,
|
|
542
|
+
}),
|
|
383
543
|
key: buildTimelineElementKey({
|
|
384
544
|
id: params.compositionId,
|
|
385
545
|
fallbackIndex: 0,
|
|
@@ -397,6 +557,7 @@ export function buildStandaloneRootTimelineElement(params: {
|
|
|
397
557
|
sourceFile: compositionSrc,
|
|
398
558
|
};
|
|
399
559
|
}
|
|
560
|
+
|
|
400
561
|
function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
401
562
|
if (doc.documentElement) {
|
|
402
563
|
doc.documentElement.style.overflow = "hidden";
|
|
@@ -500,8 +661,10 @@ export function mergeTimelineElementsPreservingDowngrades(
|
|
|
500
661
|
return nextElements;
|
|
501
662
|
}
|
|
502
663
|
|
|
503
|
-
const
|
|
504
|
-
const preserved = currentElements.filter(
|
|
664
|
+
const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
|
|
665
|
+
const preserved = currentElements.filter(
|
|
666
|
+
(element) => !nextIdentities.has(getTimelineElementIdentity(element)),
|
|
667
|
+
);
|
|
505
668
|
if (preserved.length === 0) return nextElements;
|
|
506
669
|
return [...nextElements, ...preserved];
|
|
507
670
|
}
|
|
@@ -743,7 +906,7 @@ export function useTimelinePlayer() {
|
|
|
743
906
|
(deltaFrames: number) => {
|
|
744
907
|
const adapter = getAdapter();
|
|
745
908
|
const currentTime = adapter?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
746
|
-
seek(currentTime
|
|
909
|
+
seek(stepFrameTime(currentTime, deltaFrames, STUDIO_PREVIEW_FPS));
|
|
747
910
|
},
|
|
748
911
|
[getAdapter, seek],
|
|
749
912
|
);
|
|
@@ -876,72 +1039,16 @@ export function useTimelinePlayer() {
|
|
|
876
1039
|
}
|
|
877
1040
|
const usedHostEls = new Set<Element>();
|
|
878
1041
|
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
879
|
-
|
|
1042
|
+
const hostEl = iframeDoc
|
|
880
1043
|
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
881
1044
|
: null;
|
|
882
1045
|
if (hostEl) usedHostEls.add(hostEl);
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
id,
|
|
886
|
-
tag: clip.tagName || clip.kind,
|
|
887
|
-
start: clip.start,
|
|
888
|
-
duration: clip.duration,
|
|
889
|
-
track: clip.track,
|
|
890
|
-
};
|
|
891
|
-
if (hostEl) {
|
|
892
|
-
entry.domId = hostEl.id || undefined;
|
|
893
|
-
entry.selector = getTimelineElementSelector(hostEl);
|
|
894
|
-
entry.selectorIndex =
|
|
895
|
-
iframeDoc && entry.selector
|
|
896
|
-
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
|
897
|
-
: undefined;
|
|
898
|
-
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
899
|
-
applyMediaMetadataFromElement(entry, hostEl);
|
|
900
|
-
}
|
|
901
|
-
if (clip.assetUrl) entry.src = clip.assetUrl;
|
|
902
|
-
if (clip.kind === "composition" && clip.compositionId) {
|
|
903
|
-
// The bundler renames data-composition-src to data-composition-file
|
|
904
|
-
// after inlining, so the clip manifest may not have compositionSrc.
|
|
905
|
-
// Fall back to reading data-composition-file from the DOM.
|
|
906
|
-
let resolvedSrc = clip.compositionSrc;
|
|
907
|
-
if (!resolvedSrc) {
|
|
908
|
-
hostEl =
|
|
909
|
-
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? hostEl;
|
|
910
|
-
resolvedSrc =
|
|
911
|
-
hostEl?.getAttribute("data-composition-src") ??
|
|
912
|
-
hostEl?.getAttribute("data-composition-file") ??
|
|
913
|
-
null;
|
|
914
|
-
}
|
|
915
|
-
if (resolvedSrc) {
|
|
916
|
-
entry.compositionSrc = resolvedSrc;
|
|
917
|
-
} else if (hostEl) {
|
|
918
|
-
// Inline composition (no external file) — expose inner video for thumbnails
|
|
919
|
-
const innerVideo = hostEl.querySelector("video[src]");
|
|
920
|
-
if (innerVideo) {
|
|
921
|
-
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
922
|
-
entry.tag = "video";
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
if (hostEl) {
|
|
926
|
-
const iframeDoc = iframeRef.current?.contentDocument;
|
|
927
|
-
entry.domId = hostEl.id || undefined;
|
|
928
|
-
entry.selector = getTimelineElementSelector(hostEl);
|
|
929
|
-
entry.selectorIndex =
|
|
930
|
-
iframeDoc && entry.selector
|
|
931
|
-
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
|
932
|
-
: undefined;
|
|
933
|
-
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
entry.key = buildTimelineElementKey({
|
|
937
|
-
id,
|
|
1046
|
+
return createTimelineElementFromManifestClip({
|
|
1047
|
+
clip,
|
|
938
1048
|
fallbackIndex: index,
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
selectorIndex: entry.selectorIndex,
|
|
942
|
-
sourceFile: entry.sourceFile,
|
|
1049
|
+
doc: iframeDoc,
|
|
1050
|
+
hostEl,
|
|
943
1051
|
});
|
|
944
|
-
return entry;
|
|
945
1052
|
});
|
|
946
1053
|
const rawDuration = data.durationInFrames / 30;
|
|
947
1054
|
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
@@ -1055,17 +1162,24 @@ export function useTimelinePlayer() {
|
|
|
1055
1162
|
const selector = getTimelineElementSelector(el);
|
|
1056
1163
|
const sourceFile = getTimelineElementSourceFile(el);
|
|
1057
1164
|
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
1058
|
-
const
|
|
1165
|
+
const label = getTimelineElementDisplayLabel({
|
|
1166
|
+
id: el.id || compId || null,
|
|
1167
|
+
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
1168
|
+
tag: el.tagName,
|
|
1169
|
+
});
|
|
1170
|
+
const identity = buildTimelineElementIdentity({
|
|
1171
|
+
preferredId: el.id || compId || null,
|
|
1172
|
+
label,
|
|
1173
|
+
fallbackIndex: missing.length,
|
|
1174
|
+
domId: el.id || undefined,
|
|
1175
|
+
selector,
|
|
1176
|
+
selectorIndex,
|
|
1177
|
+
sourceFile,
|
|
1178
|
+
});
|
|
1059
1179
|
const entry: TimelineElement = {
|
|
1060
|
-
id,
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
fallbackIndex: missing.length,
|
|
1064
|
-
domId: el.id || undefined,
|
|
1065
|
-
selector,
|
|
1066
|
-
selectorIndex,
|
|
1067
|
-
sourceFile,
|
|
1068
|
-
}),
|
|
1180
|
+
id: identity.id,
|
|
1181
|
+
label,
|
|
1182
|
+
key: identity.key,
|
|
1069
1183
|
tag: el.tagName.toLowerCase(),
|
|
1070
1184
|
start,
|
|
1071
1185
|
duration: dur,
|
|
@@ -1237,9 +1351,6 @@ export function useTimelinePlayer() {
|
|
|
1237
1351
|
setIsPlaying(false);
|
|
1238
1352
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
1239
1353
|
|
|
1240
|
-
const togglePlayRef = useRef(togglePlay);
|
|
1241
|
-
togglePlayRef.current = togglePlay;
|
|
1242
|
-
|
|
1243
1354
|
const refreshPlayer = useCallback(() => {
|
|
1244
1355
|
const iframe = iframeRef.current;
|
|
1245
1356
|
if (!iframe) return;
|
|
@@ -1348,6 +1459,8 @@ export function useTimelinePlayer() {
|
|
|
1348
1459
|
stopRAFLoop();
|
|
1349
1460
|
stopReverseLoop();
|
|
1350
1461
|
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.
|
|
1351
1464
|
};
|
|
1352
1465
|
});
|
|
1353
1466
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatFrameTime, frameToSeconds, secondsToFrame, formatTime } from "./time";
|
|
2
|
+
import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatTime } from "./time";
|
|
3
3
|
|
|
4
4
|
describe("formatTime", () => {
|
|
5
5
|
it("formats zero seconds", () => {
|
|
@@ -72,4 +72,14 @@ describe("frame helpers", () => {
|
|
|
72
72
|
it("formats current and total frame display", () => {
|
|
73
73
|
expect(formatFrameTime(1, 5)).toBe("30f / 150f");
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
it("steps from a truncated runtime time by integer frame index", () => {
|
|
77
|
+
expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30);
|
|
78
|
+
expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30);
|
|
79
|
+
expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("clamps frame stepping at zero", () => {
|
|
83
|
+
expect(stepFrameTime(0, -1)).toBe(0);
|
|
84
|
+
});
|
|
75
85
|
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -19,6 +19,12 @@ export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number
|
|
|
19
19
|
return frame / fps;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
23
|
+
const currentFrame = secondsToFrame(time, fps);
|
|
24
|
+
const nextFrame = Math.max(0, currentFrame + deltaFrames);
|
|
25
|
+
return frameToSeconds(nextFrame, fps);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
|
|
23
29
|
const currentFrame = secondsToFrame(time, fps);
|
|
24
30
|
const totalFrames = secondsToFrame(duration, fps);
|
package/src/styles/studio.css
CHANGED
|
@@ -49,3 +49,115 @@ body {
|
|
|
49
49
|
.cm-editor.cm-focused {
|
|
50
50
|
outline: none;
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* HyperFrames brand loader. Shared by preview overlays that need a calm,
|
|
55
|
+
* branded loading state instead of a generic spinner.
|
|
56
|
+
*/
|
|
57
|
+
.hf-loader {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
gap: 0.75rem;
|
|
63
|
+
width: min(34rem, 100%);
|
|
64
|
+
padding: 1.5rem;
|
|
65
|
+
box-sizing: border-box;
|
|
66
|
+
text-align: center;
|
|
67
|
+
cursor: default;
|
|
68
|
+
user-select: none;
|
|
69
|
+
-webkit-user-select: none;
|
|
70
|
+
-webkit-user-drag: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.hf-frame {
|
|
74
|
+
display: grid;
|
|
75
|
+
place-items: center;
|
|
76
|
+
width: 100%;
|
|
77
|
+
height: 100%;
|
|
78
|
+
min-height: 12rem;
|
|
79
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
80
|
+
background: rgba(0, 0, 0, 0.52);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.hf-loader-mark-frame {
|
|
84
|
+
display: grid;
|
|
85
|
+
place-items: center;
|
|
86
|
+
overflow: visible;
|
|
87
|
+
transform-origin: 50% 50%;
|
|
88
|
+
user-select: none;
|
|
89
|
+
-webkit-user-select: none;
|
|
90
|
+
-webkit-user-drag: none;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.hf-loader-mark {
|
|
94
|
+
display: block;
|
|
95
|
+
overflow: visible;
|
|
96
|
+
filter: drop-shadow(0 0 7px rgba(79, 219, 94, 0.2));
|
|
97
|
+
user-select: none;
|
|
98
|
+
-webkit-user-select: none;
|
|
99
|
+
-webkit-user-drag: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.hf-loader-title {
|
|
103
|
+
font-family:
|
|
104
|
+
Inter,
|
|
105
|
+
-apple-system,
|
|
106
|
+
BlinkMacSystemFont,
|
|
107
|
+
"Segoe UI",
|
|
108
|
+
sans-serif;
|
|
109
|
+
font-size: 1rem;
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
letter-spacing: 0;
|
|
112
|
+
color: var(--hf-heading, #f4f4f5);
|
|
113
|
+
max-width: 100%;
|
|
114
|
+
overflow: hidden;
|
|
115
|
+
text-overflow: ellipsis;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.hf-loader-detail {
|
|
120
|
+
max-width: 32rem;
|
|
121
|
+
min-height: 2.5rem;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
color: var(--hf-text-secondary, rgba(244, 244, 245, 0.68));
|
|
124
|
+
font-family:
|
|
125
|
+
Inter,
|
|
126
|
+
-apple-system,
|
|
127
|
+
BlinkMacSystemFont,
|
|
128
|
+
"Segoe UI",
|
|
129
|
+
sans-serif;
|
|
130
|
+
font-size: 0.82rem;
|
|
131
|
+
line-height: 1.6;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.hf-loader-mono {
|
|
135
|
+
width: min(36rem, 100%);
|
|
136
|
+
min-height: 1.5rem;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
text-overflow: ellipsis;
|
|
139
|
+
white-space: nowrap;
|
|
140
|
+
color: var(--hf-text-tertiary, rgba(244, 244, 245, 0.46));
|
|
141
|
+
font-family: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
|
|
142
|
+
font-size: 0.75rem;
|
|
143
|
+
letter-spacing: 0;
|
|
144
|
+
font-variant-numeric: tabular-nums;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.hf-loader-progress {
|
|
148
|
+
width: min(18rem, 72vw);
|
|
149
|
+
height: 0.375rem;
|
|
150
|
+
overflow: hidden;
|
|
151
|
+
border-radius: 999px;
|
|
152
|
+
background: rgba(255, 255, 255, 0.1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.hf-loader-progress__fill {
|
|
156
|
+
width: 100%;
|
|
157
|
+
height: 100%;
|
|
158
|
+
transform: scaleX(0);
|
|
159
|
+
transform-origin: left center;
|
|
160
|
+
border-radius: inherit;
|
|
161
|
+
background: linear-gradient(90deg, #06e3fa, #4fdb5e);
|
|
162
|
+
transition: transform 160ms ease;
|
|
163
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
|
|
3
|
+
|
|
4
|
+
describe("frame capture utilities", () => {
|
|
5
|
+
it("builds a PNG capture URL for the master composition", () => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
|
|
8
|
+
|
|
9
|
+
expect(
|
|
10
|
+
buildFrameCaptureUrl({
|
|
11
|
+
projectId: "demo project",
|
|
12
|
+
compositionPath: null,
|
|
13
|
+
currentTime: 1.23456,
|
|
14
|
+
origin: "http://localhost:5194",
|
|
15
|
+
}),
|
|
16
|
+
).toBe(
|
|
17
|
+
"http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
vi.useRealTimers();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("builds a safe filename from a nested composition path", () => {
|
|
24
|
+
expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "./projectRouting";
|
|
2
|
+
|
|
3
|
+
export interface FrameCaptureRequest {
|
|
4
|
+
projectId: string;
|
|
5
|
+
compositionPath: string | null;
|
|
6
|
+
currentTime: number;
|
|
7
|
+
origin?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeCompositionPath(compositionPath: string | null): string {
|
|
11
|
+
return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildFrameCaptureUrl({
|
|
15
|
+
projectId,
|
|
16
|
+
compositionPath,
|
|
17
|
+
currentTime,
|
|
18
|
+
origin = window.location.origin,
|
|
19
|
+
}: FrameCaptureRequest): string {
|
|
20
|
+
const compPath = normalizeCompositionPath(compositionPath);
|
|
21
|
+
const url = new URL(
|
|
22
|
+
buildProjectApiPath(projectId, `/thumbnail/${encodeURIComponent(compPath)}`),
|
|
23
|
+
origin,
|
|
24
|
+
);
|
|
25
|
+
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
|
|
26
|
+
url.searchParams.set("format", "png");
|
|
27
|
+
url.searchParams.set("v", String(Date.now()));
|
|
28
|
+
return url.toString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
|
|
32
|
+
const compPath = normalizeCompositionPath(compositionPath);
|
|
33
|
+
const base =
|
|
34
|
+
compPath
|
|
35
|
+
.split("/")
|
|
36
|
+
.pop()
|
|
37
|
+
?.replace(/\.html$/i, "") || "frame";
|
|
38
|
+
const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
|
|
39
|
+
return `${base}-${frameTime}s.png`;
|
|
40
|
+
}
|
package/src/utils/mediaTypes.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
|
2
2
|
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
3
3
|
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
4
|
-
export const FONT_EXT = /\.(woff|woff2|ttf|
|
|
4
|
+
export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
|
|
5
5
|
export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
|
6
6
|
|
|
7
7
|
export function isMediaFile(path: string): boolean {
|