@hyperframes/studio 0.8.81 → 0.8.83
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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
- package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
- package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
- package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
- package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
- package/dist/chunk-3IOCVWB6.js.map +1 -0
- package/dist/chunk-62DDWOSH.js +189 -0
- package/dist/chunk-62DDWOSH.js.map +1 -0
- package/dist/chunk-LPZMAGS7.js +1871 -0
- package/dist/chunk-LPZMAGS7.js.map +1 -0
- package/dist/chunk-QZ6HNNFG.js +934 -0
- package/dist/chunk-QZ6HNNFG.js.map +1 -0
- package/dist/chunk-S65AQH6M.js +101 -0
- package/dist/chunk-S65AQH6M.js.map +1 -0
- package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
- package/dist/index.d.ts +834 -330
- package/dist/index.html +1 -1
- package/dist/index.js +26378 -28802
- package/dist/index.js.map +1 -1
- package/dist/player.d.ts +30 -0
- package/dist/player.js +10 -0
- package/dist/player.js.map +1 -0
- package/dist/ui.d.ts +294 -0
- package/dist/ui.js +66 -0
- package/dist/ui.js.map +1 -0
- package/package.json +16 -8
- package/src/components/StudioHeader.tsx +1 -121
- package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
- package/src/components/TimelineHistoryButtons.tsx +26 -14
- package/src/components/TimelineToolbar.test.tsx +9 -1
- package/src/components/TimelineToolbar.tsx +13 -5
- package/src/components/editor/BlockParamsPanel.tsx +14 -10
- package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -21
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/SnapGuideOverlay.tsx +1 -1
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
- package/src/components/editor/clipAudioClock.test.ts +61 -0
- package/src/components/editor/clipAudioClock.ts +78 -0
- package/src/components/editor/manualEdits.test.ts +24 -0
- package/src/components/editor/manualEdits.ts +6 -2
- package/src/components/editor/manualEditsDom.ts +0 -65
- package/src/components/editor/manualEditsSeekReapply.ts +136 -0
- package/src/components/editor/marqueeCommit.ts +100 -37
- package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
- package/src/components/editor/propertyPanelHelpers.ts +15 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
- package/src/components/editor/useFxCarve.ts +11 -15
- package/src/components/editor/useFxCarveNodes.ts +27 -15
- package/src/components/editor/useFxLevelling.ts +20 -31
- package/src/components/editor/useInlineTextEditing.tsx +35 -19
- package/src/components/feedback/projectProvenance.ts +2 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
- package/src/components/nle/AudioMeterStrip.tsx +23 -12
- package/src/components/nle/PreviewOverlays.tsx +7 -188
- package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
- package/src/components/pointerTargetSize.test.tsx +1 -1
- package/src/components/studioColorGradingScope.ts +12 -36
- package/src/components/ui/HyperframesLogo.tsx +120 -0
- package/src/contexts/TimelineEditContext.test.tsx +49 -0
- package/src/contexts/TimelineEditContext.tsx +27 -23
- package/src/domEditExports.test.tsx +69 -0
- package/src/editorGesturesExports.test.tsx +236 -0
- package/src/hooks/canvasQuickSaves.test.tsx +195 -0
- package/src/hooks/gsapMutationClient.ts +116 -0
- package/src/hooks/resizeObserverTestUtils.ts +14 -0
- package/src/hooks/timelineAudioGroupCreate.ts +18 -40
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +66 -61
- package/src/hooks/timelineQuickSaves.test.ts +195 -0
- package/src/hooks/timelineTimingSync.test.ts +12 -0
- package/src/hooks/timelineTimingSync.ts +31 -123
- package/src/hooks/timelineTrackVisibility.ts +15 -29
- package/src/hooks/useAskAgentModal.test.tsx +78 -0
- package/src/hooks/useAskAgentModal.ts +3 -1
- package/src/hooks/useClipboard.source.test.ts +32 -0
- package/src/hooks/useClipboard.ts +21 -20
- package/src/hooks/useDomEditCommits.test.tsx +13 -9
- package/src/hooks/useDomEditCommits.ts +88 -97
- package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +37 -38
- package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
- package/src/hooks/useFileManager.ts +21 -12
- package/src/hooks/useGroupCommits.ts +23 -24
- package/src/hooks/useGsapAnimationOps.ts +26 -8
- package/src/hooks/useGsapScriptCommits.ts +1 -0
- package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
- package/src/hooks/useKeyframeKeyboard.ts +9 -71
- package/src/hooks/usePreviewPersistence.ts +1 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
- package/src/hooks/useThumbnailStripSize.ts +31 -0
- package/src/hooks/useTimelineAssetDropOps.ts +38 -35
- package/src/hooks/useTimelineDeleteOps.ts +53 -56
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +53 -32
- package/src/lightEntries.test.ts +32 -0
- package/src/player/components/CompositionThumbnail.test.ts +40 -6
- package/src/player/components/CompositionThumbnail.tsx +6 -21
- package/src/player/components/ImageThumbnail.test.tsx +27 -6
- package/src/player/components/ImageThumbnail.tsx +4 -18
- package/src/player/components/PlayerControls.test.tsx +52 -0
- package/src/player/components/PlayerControls.tsx +2 -1
- package/src/player/components/Timeline.test.ts +28 -3
- package/src/player/components/TimelineCanvas.tsx +5 -3
- package/src/player/components/TimelineClipFades.test.tsx +31 -10
- package/src/player/components/TimelineClipFades.tsx +2 -1
- package/src/player/components/TimelineTypes.ts +4 -2
- package/src/player/components/VideoThumbnail.test.tsx +33 -11
- package/src/player/components/VideoThumbnail.tsx +7 -29
- package/src/player/components/studioShortcuts.ts +8 -5
- package/src/player/components/thumbnailUtils.test.ts +16 -20
- package/src/player/components/thumbnailUtils.ts +6 -7
- package/src/player/components/timelineClipChildren.test.ts +34 -1
- package/src/player/components/timelineClipChildren.tsx +7 -4
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
- package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
- package/src/player/components/timelineEditing.test.ts +55 -0
- package/src/player/components/timelineGroupEditing.ts +13 -6
- package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
- package/src/player/components/timelinePlayheadTransform.ts +17 -0
- package/src/player/components/timelinePointerRelease.ts +4 -0
- package/src/player/components/useTimelinePlayhead.ts +26 -8
- package/src/player/components/useTimelineProviderState.tsx +4 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
- package/src/player/components/useTimelineStackingSync.ts +18 -11
- package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
- package/src/player/hooks/timelinePlayerSync.ts +18 -8
- package/src/player/hooks/useShadowPreviewReload.ts +15 -4
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
- package/src/player/lib/playbackShortcuts.ts +18 -0
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +92 -0
- package/src/player/lib/timelineDOM.ts +0 -2
- package/src/player/lib/timelineElementHelpers.ts +47 -10
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/store/timelineElement.ts +2 -1
- package/src/playerEntry.ts +1 -0
- package/src/timelineChromeExports.test.tsx +79 -0
- package/src/timelineStackingSyncExport.test.tsx +67 -0
- package/src/ui.ts +31 -0
- package/src/utils/authoredSource.ts +15 -0
- package/src/utils/blockInstaller.ts +32 -27
- package/src/utils/mediaTypes.test.ts +33 -0
- package/src/utils/mediaTypes.ts +12 -5
- package/src/utils/studioFileHistory.test.ts +14 -14
- package/src/utils/studioFileHistory.ts +31 -2
- package/dist/chunk-I7XNAIOF.js.map +0 -1
- /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
10
10
|
import { computeResizePreview } from "../components/timelineClipDragPreview";
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
+
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
12
13
|
|
|
13
14
|
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
|
14
15
|
return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
|
|
@@ -46,6 +47,26 @@ describe("parseTimelineFromDOM — media in-point", () => {
|
|
|
46
47
|
});
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
describe("parseTimelineFromDOM — in-point read as playback reads it", () => {
|
|
51
|
+
it.each([
|
|
52
|
+
['data-playback-start="-1" data-media-start="2"', 2, "playback-start"],
|
|
53
|
+
['data-playback-start="abc" data-media-start="2"', 2, "playback-start"],
|
|
54
|
+
['data-media-start="junk"', 0, "media-start"],
|
|
55
|
+
['data-media-start="1.5s"', 0, "media-start"],
|
|
56
|
+
])("%s", (inPoint, playbackStart, playbackStartAttr) => {
|
|
57
|
+
const doc = makeDoc(
|
|
58
|
+
`<div data-composition-id="root"><video id="v" class="clip" data-start="0" data-duration="4" ${inPoint}></video></div>`,
|
|
59
|
+
);
|
|
60
|
+
const element = parseTimelineFromDOM(doc, 10).find((e) => e.domId === "v")!;
|
|
61
|
+
const video = doc.getElementById("v")!;
|
|
62
|
+
expect(element.playbackStart).toBe(readMediaOffsetSeconds((n) => video.getAttribute(n)));
|
|
63
|
+
expect([element.playbackStart, element.playbackStartAttr]).toEqual([
|
|
64
|
+
playbackStart,
|
|
65
|
+
playbackStartAttr,
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
49
70
|
describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
50
71
|
it("bridges a real GSAP transition marker onto both named clips", () => {
|
|
51
72
|
document.body.innerHTML = `
|
|
@@ -526,3 +547,74 @@ describe("a composition clip's source length", () => {
|
|
|
526
547
|
expect(card?.sourceDuration).toBeUndefined();
|
|
527
548
|
});
|
|
528
549
|
});
|
|
550
|
+
|
|
551
|
+
describe("what the live clip list says a clip plays", () => {
|
|
552
|
+
function manifestVideo(attrs: string): TimelineElement {
|
|
553
|
+
const doc = makeDoc(
|
|
554
|
+
`<div data-composition-id="root"><video id="v" src="a.mp4" data-start="0" data-duration="4" ${attrs}></video></div>`,
|
|
555
|
+
);
|
|
556
|
+
return createTimelineElementFromManifestClip({
|
|
557
|
+
clip: {
|
|
558
|
+
id: "v",
|
|
559
|
+
label: "v",
|
|
560
|
+
kind: "video",
|
|
561
|
+
tagName: "video",
|
|
562
|
+
start: 0,
|
|
563
|
+
duration: 4,
|
|
564
|
+
track: 0,
|
|
565
|
+
assetUrl: null,
|
|
566
|
+
},
|
|
567
|
+
fallbackIndex: 0,
|
|
568
|
+
doc,
|
|
569
|
+
hostEl: doc.getElementById("v"),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
it("carries data-volume and muted from the element", () => {
|
|
574
|
+
expect(manifestVideo('data-volume="0" muted')).toMatchObject({ volume: 0, muted: true });
|
|
575
|
+
const plain = manifestVideo("");
|
|
576
|
+
expect(plain.volume).toBeUndefined();
|
|
577
|
+
expect(plain.muted).toBeUndefined();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("carries the clip-edge fades, and drops a zero one", () => {
|
|
581
|
+
const faded = manifestVideo('data-fade-in="1.5" data-fade-out="0"');
|
|
582
|
+
expect(faded.fadeIn).toBe(1.5);
|
|
583
|
+
expect(faded.fadeOut).toBeUndefined();
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("gives a video with neither muted nor data-has-audio sound, as the compiler does", () => {
|
|
587
|
+
expect(manifestVideo("").hasAudio).toBe(true);
|
|
588
|
+
expect(manifestVideo("muted").hasAudio).toBeUndefined();
|
|
589
|
+
expect(manifestVideo('data-has-audio="false"').hasAudio).toBeUndefined();
|
|
590
|
+
expect(manifestVideo('muted data-has-audio="true"')).toMatchObject({
|
|
591
|
+
hasAudio: true,
|
|
592
|
+
muted: true,
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it.each(["", ' data-has-audio="true"'])(
|
|
597
|
+
"does not give a timed wrapper the sound of an untimed video%s inside it",
|
|
598
|
+
(videoAttrs) => {
|
|
599
|
+
const doc = makeDoc(
|
|
600
|
+
`<div data-composition-id="root"><div id="w" data-start="0" data-duration="4"><video src="a.mp4"${videoAttrs}></video></div></div>`,
|
|
601
|
+
);
|
|
602
|
+
const wrapper = createTimelineElementFromManifestClip({
|
|
603
|
+
clip: {
|
|
604
|
+
id: "w",
|
|
605
|
+
label: "w",
|
|
606
|
+
kind: "element",
|
|
607
|
+
tagName: "div",
|
|
608
|
+
start: 0,
|
|
609
|
+
duration: 4,
|
|
610
|
+
track: 0,
|
|
611
|
+
assetUrl: null,
|
|
612
|
+
},
|
|
613
|
+
fallbackIndex: 0,
|
|
614
|
+
doc,
|
|
615
|
+
hostEl: doc.getElementById("w"),
|
|
616
|
+
});
|
|
617
|
+
expect(wrapper.hasAudio).toBeUndefined();
|
|
618
|
+
},
|
|
619
|
+
);
|
|
620
|
+
});
|
|
@@ -304,8 +304,6 @@ export function parseTimelineFromDOM(
|
|
|
304
304
|
if (mediaEl.tagName === "IMG") {
|
|
305
305
|
entry.tag = "img";
|
|
306
306
|
}
|
|
307
|
-
const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
|
|
308
|
-
if (vol) entry.volume = parseFloat(vol);
|
|
309
307
|
// Override AFTER the helper (which sets the raw relative attribute) so the
|
|
310
308
|
// resolved absolute URL wins — the Studio can then fetch the asset
|
|
311
309
|
// regardless of whether the attribute value was relative or absolute.
|
|
@@ -13,7 +13,7 @@ import { isFinitePositive } from "./playbackAdapter";
|
|
|
13
13
|
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
14
|
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
15
15
|
import { readElementFades } from "@hyperframes/core/audio-fade";
|
|
16
|
-
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
16
|
+
import { type AttrReader, readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Layer-reveal lift transparency
|
|
@@ -168,14 +168,34 @@ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageEl
|
|
|
168
168
|
: null;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/** The in-point as playback reads it, and the attribute holding it; empty when neither is authored. */
|
|
172
|
+
export function readPlaybackStartAttributes(
|
|
173
|
+
getAttr: AttrReader,
|
|
174
|
+
): Pick<TimelineElement, "playbackStart" | "playbackStartAttr"> {
|
|
175
|
+
const playbackStartAttr =
|
|
176
|
+
getAttr("data-playback-start") != null
|
|
177
|
+
? "playback-start"
|
|
178
|
+
: getAttr("data-media-start") != null
|
|
179
|
+
? "media-start"
|
|
180
|
+
: undefined;
|
|
181
|
+
return playbackStartAttr
|
|
182
|
+
? { playbackStart: readMediaOffsetSeconds(getAttr), playbackStartAttr }
|
|
183
|
+
: {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function playbackStartAttributeForElement(
|
|
187
|
+
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
|
|
188
|
+
): "data-media-start" | "data-playback-start" {
|
|
189
|
+
return element.playbackStartAttr === "playback-start" || element.kind === "composition"
|
|
190
|
+
? "data-playback-start"
|
|
191
|
+
: "data-media-start";
|
|
192
|
+
}
|
|
193
|
+
|
|
171
194
|
function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
entry.playbackStart = readMediaOffsetSeconds((n) => el.getAttribute(n));
|
|
177
|
-
if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
|
|
178
|
-
else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
|
|
195
|
+
Object.assign(
|
|
196
|
+
entry,
|
|
197
|
+
readPlaybackStartAttributes((n) => el.getAttribute(n)),
|
|
198
|
+
);
|
|
179
199
|
|
|
180
200
|
const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
|
|
181
201
|
if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
|
|
@@ -193,9 +213,25 @@ function setOptional<K extends keyof TimelineElement>(
|
|
|
193
213
|
else entry[key] = value;
|
|
194
214
|
}
|
|
195
215
|
|
|
196
|
-
|
|
216
|
+
function readVolume(el: Element, media: Element): number | undefined {
|
|
217
|
+
const volume = Number.parseFloat(
|
|
218
|
+
el.getAttribute("data-volume") ?? media.getAttribute("data-volume") ?? "",
|
|
219
|
+
);
|
|
220
|
+
return Number.isFinite(volume) ? volume : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
|
|
197
224
|
function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
198
|
-
|
|
225
|
+
const media = resolveMediaElement(el) ?? el;
|
|
226
|
+
const muted = el.hasAttribute("muted") || media.hasAttribute("muted");
|
|
227
|
+
const hasAudio = el.getAttribute("data-has-audio");
|
|
228
|
+
const sound = hasAudio === null ? el.tagName === "VIDEO" && !muted : hasAudio === "true";
|
|
229
|
+
setOptional(entry, "hasAudio", sound ? true : undefined);
|
|
230
|
+
setOptional(entry, "muted", muted ? true : undefined);
|
|
231
|
+
setOptional(entry, "volume", readVolume(el, media));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function applyFadeMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
199
235
|
const fades = readElementFades(el);
|
|
200
236
|
setOptional(entry, "fadeIn", fades.fadeIn > 0 ? fades.fadeIn : undefined);
|
|
201
237
|
setOptional(entry, "fadeOut", fades.fadeOut > 0 ? fades.fadeOut : undefined);
|
|
@@ -204,6 +240,7 @@ function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): voi
|
|
|
204
240
|
export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
205
241
|
applyPlaybackMetadataFromElement(entry, el);
|
|
206
242
|
applyAudioMetadataFromElement(entry, el);
|
|
243
|
+
applyFadeMetadataFromElement(entry, el);
|
|
207
244
|
|
|
208
245
|
const mediaEl = resolveMediaElement(el);
|
|
209
246
|
if (!mediaEl) return;
|
|
@@ -49,7 +49,7 @@ export interface TimelineViewportBudgets {
|
|
|
49
49
|
|
|
50
50
|
const MEBIBYTE = 1024 * 1024;
|
|
51
51
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
52
|
-
export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // 4K
|
|
52
|
+
export const MAX_VISIBLE_THUMBNAIL_FRAMES = Math.ceil(3840 / (66 * (16 / 9))); // Decode-memory cap, 4K width / a 66 px 16:9 tile; 40 px clips reuse frames past it.
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
55
|
* The sole default budget owner for timeline viewport and media virtualization.
|
|
@@ -55,8 +55,9 @@ export interface TimelineElement {
|
|
|
55
55
|
playbackRate?: number;
|
|
56
56
|
sourceDuration?: number;
|
|
57
57
|
volume?: number;
|
|
58
|
-
/** `data-has-audio
|
|
58
|
+
/** A video with sound to mix (`data-has-audio`, or unmuted without it); `muted` silences it. */
|
|
59
59
|
hasAudio?: boolean;
|
|
60
|
+
muted?: boolean;
|
|
60
61
|
/** Clip-edge fades from `data-fade-in` / `data-fade-out`, seconds; absent means none. */
|
|
61
62
|
fadeIn?: number;
|
|
62
63
|
fadeOut?: number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Player } from "./player/components/Player";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Mounts the timeline chrome by package name with no StudioShellProvider, as a host app does.
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
AudioMeterStrip,
|
|
7
|
+
TimelineHistoryButtons,
|
|
8
|
+
TimelineToolbar,
|
|
9
|
+
useClipboard,
|
|
10
|
+
usePlayerStore,
|
|
11
|
+
type UseClipboardOptions,
|
|
12
|
+
} from "@hyperframes/studio";
|
|
13
|
+
import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
|
|
14
|
+
import { useAudioMetersVisible } from "./utils/audioMeterVisibility";
|
|
15
|
+
|
|
16
|
+
installReactActEnvironment();
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
usePlayerStore.setState({ elements: [], selectedElementId: null });
|
|
21
|
+
useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("timeline chrome package exports, outside Studio's shell", () => {
|
|
25
|
+
it("mounts the toolbar with the host's history and no Add beat", async () => {
|
|
26
|
+
const onUndo = vi.fn();
|
|
27
|
+
const root = mountReactHarness(
|
|
28
|
+
<TimelineToolbar history={{ canUndo: true, onUndo }} showAddBeat={false} />,
|
|
29
|
+
);
|
|
30
|
+
const undo = document.querySelector<HTMLButtonElement>('button[aria-label="Undo"]');
|
|
31
|
+
act(() => undo?.click());
|
|
32
|
+
expect(onUndo).toHaveBeenCalledTimes(1);
|
|
33
|
+
expect(document.querySelector('button[aria-label="Add beat at playhead"]')).toBeNull();
|
|
34
|
+
await act(async () => root.unmount());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("mounts the history buttons disabled when given nothing", async () => {
|
|
38
|
+
const root = mountReactHarness(<TimelineHistoryButtons />);
|
|
39
|
+
const buttons = Array.from(document.querySelectorAll("button"));
|
|
40
|
+
expect(buttons.map((b) => b.disabled)).toEqual([true, true]);
|
|
41
|
+
await act(async () => root.unmount());
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("mounts the meter strip on the host's preview iframe", async () => {
|
|
45
|
+
usePlayerStore.setState({
|
|
46
|
+
elements: [{ id: "music", tag: "audio", start: 0, duration: 4, track: 0 }],
|
|
47
|
+
});
|
|
48
|
+
useAudioMetersVisible.setState({ visible: true });
|
|
49
|
+
const root = mountReactHarness(<AudioMeterStrip previewIframeRef={{ current: null }} />);
|
|
50
|
+
expect(document.querySelector('[data-testid="audio-meter-strip"]')).not.toBeNull();
|
|
51
|
+
await act(async () => root.unmount());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("runs the clipboard with no DOM edit session", async () => {
|
|
55
|
+
const toasts: string[] = [];
|
|
56
|
+
const options: UseClipboardOptions = {
|
|
57
|
+
projectId: "p",
|
|
58
|
+
activeCompPath: "index.html",
|
|
59
|
+
domEditSelectionRef: { current: null },
|
|
60
|
+
showToast: (message) => toasts.push(message),
|
|
61
|
+
writeProjectFile: async () => {},
|
|
62
|
+
recordEdit: async () => {},
|
|
63
|
+
reloadPreview: () => {},
|
|
64
|
+
handleTimelineElementsDelete: async () => {},
|
|
65
|
+
handleDomEditElementDelete: async () => {},
|
|
66
|
+
previewIframeRef: { current: null },
|
|
67
|
+
waitForPendingDomEditSaves: async () => {},
|
|
68
|
+
};
|
|
69
|
+
const api: { current: ReturnType<typeof useClipboard> | null } = { current: null };
|
|
70
|
+
function Harness() {
|
|
71
|
+
api.current = useClipboard(options);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const root = mountReactHarness(<Harness />);
|
|
75
|
+
expect(api.current?.handleCopy()).toBe(false);
|
|
76
|
+
expect(toasts).toEqual(["Nothing selected to copy."]);
|
|
77
|
+
await act(async () => root.unmount());
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Mounts the timeline by package name with no Studio shell, as a host app does.
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import { Timeline, usePlayerStore, type TimelineZIndexReorderCommit } from "@hyperframes/studio";
|
|
6
|
+
import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
|
|
7
|
+
|
|
8
|
+
installReactActEnvironment();
|
|
9
|
+
// happy-dom has no layout; the timeline only renders clips inside a sized viewport.
|
|
10
|
+
for (const [prop, px] of [
|
|
11
|
+
["clientWidth", 1000],
|
|
12
|
+
["clientHeight", 400],
|
|
13
|
+
["offsetWidth", 1000],
|
|
14
|
+
["offsetHeight", 400],
|
|
15
|
+
] as const) {
|
|
16
|
+
vi.spyOn(HTMLElement.prototype, prop, "get").mockReturnValue(px);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const settle = () => act(async () => new Promise((r) => setTimeout(r, 50)));
|
|
20
|
+
const pointer = (type: string, clientY: number) =>
|
|
21
|
+
new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX: 150, clientY });
|
|
22
|
+
|
|
23
|
+
it("restacks the host's preview when a lane move puts a clip below another", async () => {
|
|
24
|
+
const iframe = document.createElement("iframe");
|
|
25
|
+
document.body.appendChild(iframe);
|
|
26
|
+
const doc = iframe.contentDocument!;
|
|
27
|
+
// No authored z: DOM order paints tag over title, which matches the rows until the move.
|
|
28
|
+
doc.body.innerHTML = '<div id="title"></div><div id="tag"></div><div id="note"></div>';
|
|
29
|
+
const clip = (id: string, start: number, duration: number, track: number) =>
|
|
30
|
+
({ id, key: id, domId: id, tag: "div", start, duration, track }) as const;
|
|
31
|
+
usePlayerStore.setState({
|
|
32
|
+
duration: 10,
|
|
33
|
+
timelineReady: true,
|
|
34
|
+
elements: [clip("title", 0, 4, 1), clip("tag", 0, 4, 0), clip("note", 6, 2, 2)],
|
|
35
|
+
});
|
|
36
|
+
const commit = vi.fn<TimelineZIndexReorderCommit>(async (entries) => {
|
|
37
|
+
for (const e of entries) e.element.style.zIndex = String(e.zIndex);
|
|
38
|
+
});
|
|
39
|
+
const root = mountReactHarness(
|
|
40
|
+
<Timeline
|
|
41
|
+
previewIframeRef={{ current: iframe }}
|
|
42
|
+
onZIndexReorder={commit}
|
|
43
|
+
onMoveElement={async () => {}}
|
|
44
|
+
onMoveElements={async () => {}}
|
|
45
|
+
/>,
|
|
46
|
+
);
|
|
47
|
+
await settle();
|
|
48
|
+
const tagClip = document.querySelector<HTMLElement>('.timeline-clip[aria-label^="tag,"]')!;
|
|
49
|
+
vi.spyOn(tagClip, "getBoundingClientRect").mockReturnValue(new DOMRect(80, 77, 360, 42));
|
|
50
|
+
|
|
51
|
+
// Drag tag from the top row to the bottom row, clear of note in time.
|
|
52
|
+
act(() => void tagClip.dispatchEvent(pointer("pointerdown", 98)));
|
|
53
|
+
act(() => {
|
|
54
|
+
window.dispatchEvent(pointer("pointermove", 140));
|
|
55
|
+
window.dispatchEvent(pointer("pointermove", 194));
|
|
56
|
+
});
|
|
57
|
+
await act(async () => void window.dispatchEvent(pointer("pointerup", 194)));
|
|
58
|
+
await settle();
|
|
59
|
+
|
|
60
|
+
expect(usePlayerStore.getState().elements.find((e) => e.id === "tag")?.track).toBe(2);
|
|
61
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(commit.mock.calls[0]![0].every((e) => e.element.ownerDocument === doc)).toBe(true);
|
|
63
|
+
const z = (id: string) => Number(doc.getElementById(id)!.style.zIndex || 0);
|
|
64
|
+
expect(z("title")).toBeGreaterThan(z("tag"));
|
|
65
|
+
await act(async () => root.unmount());
|
|
66
|
+
usePlayerStore.setState({ elements: [], timelineReady: false });
|
|
67
|
+
});
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
|
|
2
|
+
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
|
|
3
|
+
export { HyperframesLogo } from "./components/ui/HyperframesLogo";
|
|
4
|
+
export { IconButton } from "./components/ui/IconButton";
|
|
5
|
+
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
|
|
6
|
+
export { HyperframesLoader } from "./components/ui/HyperframesLoader";
|
|
7
|
+
export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
|
|
8
|
+
export { Tooltip } from "./components/ui/Tooltip";
|
|
9
|
+
export { cn } from "./components/ui/cn";
|
|
10
|
+
export {
|
|
11
|
+
ContextMenu,
|
|
12
|
+
Menu,
|
|
13
|
+
MenuItem,
|
|
14
|
+
MenuRadioGroup,
|
|
15
|
+
MenuRadioItem,
|
|
16
|
+
MenuSeparator,
|
|
17
|
+
MenuShortcut,
|
|
18
|
+
popupSurface,
|
|
19
|
+
} from "./components/ui/Menu";
|
|
20
|
+
export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
|
|
21
|
+
export { Popover } from "./components/ui/Popover";
|
|
22
|
+
export { Input, fieldBase, fieldText } from "./components/ui/Input";
|
|
23
|
+
export type { InputProps } from "./components/ui/Input";
|
|
24
|
+
export { NumberField } from "./components/ui/NumberField";
|
|
25
|
+
export type { NumberFieldProps } from "./components/ui/NumberField";
|
|
26
|
+
export { Select } from "./components/ui/Select";
|
|
27
|
+
export type { SelectOption, SelectProps } from "./components/ui/Select";
|
|
28
|
+
export { Slider } from "./components/ui/Slider";
|
|
29
|
+
export type { SliderProps } from "./components/ui/Slider";
|
|
30
|
+
export { Toggle } from "./components/ui/Toggle";
|
|
31
|
+
export type { ToggleProps } from "./components/ui/Toggle";
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
2
|
+
import {
|
|
3
|
+
STUDIO_PREVIEW_LAZY_ATTR,
|
|
4
|
+
STUDIO_PREVIEW_UPCOMING_ATTR,
|
|
5
|
+
} from "@hyperframes/core/studio-preview-mark";
|
|
2
6
|
|
|
3
7
|
// Stamped as the preview stamps the files it serves, so a live element's hf-id finds its source.
|
|
4
8
|
export function parseSavedSource(html: string): Document {
|
|
@@ -90,3 +94,14 @@ export function authoredMarkup(authored: Element, live: Element, sourceFile: str
|
|
|
90
94
|
}
|
|
91
95
|
return copy.outerHTML;
|
|
92
96
|
}
|
|
97
|
+
|
|
98
|
+
export function liveMarkupWithoutPreviewMarks(live: Element): string {
|
|
99
|
+
const copy = live.cloneNode(true) as Element;
|
|
100
|
+
for (const el of [copy, ...Array.from(copy.querySelectorAll("*"))]) {
|
|
101
|
+
el.removeAttribute(STUDIO_PREVIEW_UPCOMING_ATTR);
|
|
102
|
+
if (!el.hasAttribute(STUDIO_PREVIEW_LAZY_ATTR)) continue;
|
|
103
|
+
el.removeAttribute("loading");
|
|
104
|
+
el.removeAttribute(STUDIO_PREVIEW_LAZY_ATTR);
|
|
105
|
+
}
|
|
106
|
+
return copy.outerHTML;
|
|
107
|
+
}
|
|
@@ -9,6 +9,7 @@ import { collectHtmlIds } from "./studioHelpers";
|
|
|
9
9
|
import { generateId } from "./generateId";
|
|
10
10
|
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
11
11
|
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
12
|
+
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
|
|
12
13
|
import { extendRootDurationInSource } from "./rootDuration";
|
|
13
14
|
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
14
15
|
|
|
@@ -93,12 +94,14 @@ async function makeComponentBackgroundTransparent(
|
|
|
93
94
|
writeProjectFile: AddBlockOptions["writeProjectFile"],
|
|
94
95
|
): Promise<void> {
|
|
95
96
|
if (block.type !== "hyperframes:component") return;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
await serializeStudioFileMutation(writeProjectFile, compositionFile, async () => {
|
|
98
|
+
const content = await readProjectFile(compositionFile);
|
|
99
|
+
const transparentContent = content.replace(
|
|
100
|
+
/background:\s*(?:#(?:0a0a0a|000000|000|0a0805)|rgba?\([^)]*\))\s*;/g,
|
|
101
|
+
"background: transparent;",
|
|
102
|
+
);
|
|
103
|
+
if (transparentContent !== content) await writeProjectFile(compositionFile, transparentContent);
|
|
104
|
+
});
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
function resolveBlockPlacement({
|
|
@@ -201,7 +204,6 @@ export async function addBlockToProject(
|
|
|
201
204
|
);
|
|
202
205
|
|
|
203
206
|
const targetPath = activeCompPath || "index.html";
|
|
204
|
-
const originalContent = await readProjectFile(targetPath);
|
|
205
207
|
const relevantElements = timelineElements.filter(
|
|
206
208
|
(element) => (element.sourceFile || targetPath) === targetPath,
|
|
207
209
|
);
|
|
@@ -211,29 +213,32 @@ export async function addBlockToProject(
|
|
|
211
213
|
timelineElements: relevantElements,
|
|
212
214
|
currentTime: opts.currentTime ?? 0,
|
|
213
215
|
});
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
216
|
+
let hostId = "";
|
|
217
|
+
const insertHost = (originalContent: string) => {
|
|
218
|
+
const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
|
|
219
|
+
hostId = buildUniqueCompositionId(block.name, collectHtmlIds(originalContent));
|
|
220
|
+
const subComposition = buildSubCompositionHtml({
|
|
221
|
+
id: hostId,
|
|
222
|
+
compositionFile,
|
|
223
|
+
start,
|
|
224
|
+
duration,
|
|
225
|
+
track,
|
|
226
|
+
width,
|
|
227
|
+
height,
|
|
228
|
+
left: visualPosition ? Math.round(visualPosition.left) : 0,
|
|
229
|
+
top: visualPosition ? Math.round(visualPosition.top) : 0,
|
|
230
|
+
zIndex: getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1,
|
|
231
|
+
});
|
|
232
|
+
return extendRootDurationInSource(
|
|
233
|
+
insertTimelineAssetIntoSource(originalContent, subComposition),
|
|
234
|
+
start + duration,
|
|
235
|
+
);
|
|
236
|
+
};
|
|
232
237
|
await saveProjectFilesWithHistory({
|
|
233
238
|
projectId,
|
|
234
239
|
label: `Add ${isBlock ? "block" : "component"}: ${block.title}`,
|
|
235
|
-
files: { [targetPath]:
|
|
236
|
-
readFile:
|
|
240
|
+
files: { [targetPath]: insertHost },
|
|
241
|
+
readFile: readProjectFile,
|
|
237
242
|
writeFile: writeProjectFile,
|
|
238
243
|
recordEdit,
|
|
239
244
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AUDIO_EXT, IMAGE_EXT, isMediaFile, MEDIA_EXT, VIDEO_EXT } from "./mediaTypes";
|
|
3
|
+
import { getTimelineAssetKind } from "./timelineAssetDrop";
|
|
4
|
+
|
|
5
|
+
describe("media types", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
["clip.m4v", "video"],
|
|
8
|
+
["assets/Take 2.M4V", "video"],
|
|
9
|
+
["song.flac", "audio"],
|
|
10
|
+
["voice.FLAC", "audio"],
|
|
11
|
+
["clip.mp4", "video"],
|
|
12
|
+
["music.mp3", "audio"],
|
|
13
|
+
["still.png", "image"],
|
|
14
|
+
] as const)("the timeline takes %s as %s", (path, kind) => {
|
|
15
|
+
expect(getTimelineAssetKind(path)).toBe(kind);
|
|
16
|
+
expect(isMediaFile(path)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("still refuses files that are not media", () => {
|
|
20
|
+
for (const path of ["notes.pdf", "font.woff2", "grade.cube", "clip.m4v.txt", "flac"]) {
|
|
21
|
+
expect(getTimelineAssetKind(path)).toBeNull();
|
|
22
|
+
expect(isMediaFile(path)).toBe(false);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("counts a file as media exactly when it is an image, video or audio file", () => {
|
|
27
|
+
const paths = ["a.mp4", "a.m4v", "a.flac", "a.png", "a.ico", "a.woff", "a.pdf", "a.cube"];
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
const kinds = [IMAGE_EXT, VIDEO_EXT, AUDIO_EXT].filter((re) => re.test(path)).length;
|
|
30
|
+
expect(MEDIA_EXT.test(path)).toBe(kinds > 0);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/utils/mediaTypes.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
const IMAGE_EXTS = ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "ico"];
|
|
2
|
+
const VIDEO_EXTS = ["mp4", "webm", "mov", "m4v"];
|
|
3
|
+
const AUDIO_EXTS = ["mp3", "wav", "ogg", "m4a", "aac", "flac"];
|
|
4
|
+
|
|
5
|
+
function extensionPattern(extensions: readonly string[]): RegExp {
|
|
6
|
+
return new RegExp(`\\.(${extensions.join("|")})$`, "i");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const IMAGE_EXT = extensionPattern(IMAGE_EXTS);
|
|
10
|
+
export const VIDEO_EXT = extensionPattern(VIDEO_EXTS);
|
|
11
|
+
export const AUDIO_EXT = extensionPattern(AUDIO_EXTS);
|
|
4
12
|
export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
|
|
5
13
|
export const LUT_EXT = /\.cube$/i;
|
|
6
|
-
export const MEDIA_EXT =
|
|
7
|
-
/\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|avif|svg|ico)$/i;
|
|
14
|
+
export const MEDIA_EXT = extensionPattern([...VIDEO_EXTS, ...AUDIO_EXTS, ...IMAGE_EXTS]);
|
|
8
15
|
|
|
9
16
|
export function isMediaFile(path: string): boolean {
|
|
10
17
|
return MEDIA_EXT.test(path);
|