@hyperframes/studio 0.8.107 → 0.8.109
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-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
12
12
|
import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
|
|
13
13
|
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
14
14
|
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
|
15
|
+
import { linkScopeOf } from "@hyperframes/core/media-link";
|
|
15
16
|
import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
|
|
16
17
|
import { groupInfoFor } from "./timelineGroupInfo";
|
|
17
18
|
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
|
|
@@ -150,6 +151,12 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
150
151
|
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
|
|
151
152
|
const timelineRole = hostEl.getAttribute("data-timeline-role");
|
|
152
153
|
if (timelineRole) entry.timelineRole = timelineRole;
|
|
154
|
+
const link = hostEl.getAttribute("data-link");
|
|
155
|
+
if (link) entry.link = link;
|
|
156
|
+
const compositionScope = linkScopeOf(hostEl)?.getAttribute("data-composition-id");
|
|
157
|
+
if (compositionScope) entry.compositionScope = compositionScope;
|
|
158
|
+
const syncOrigin = hostEl.getAttribute("data-sync-origin");
|
|
159
|
+
if (syncOrigin) entry.syncOrigin = syncOrigin;
|
|
153
160
|
const audioGroup = hostEl.getAttribute("data-audio-group");
|
|
154
161
|
if (audioGroup) {
|
|
155
162
|
entry.audioGroup = audioGroup;
|
|
@@ -337,6 +344,12 @@ export function parseTimelineFromDOM(
|
|
|
337
344
|
|
|
338
345
|
const timelineRole = el.getAttribute("data-timeline-role");
|
|
339
346
|
if (timelineRole) entry.timelineRole = timelineRole;
|
|
347
|
+
const domLink = el.getAttribute("data-link");
|
|
348
|
+
if (domLink) entry.link = domLink;
|
|
349
|
+
const domCompositionScope = linkScopeOf(el)?.getAttribute("data-composition-id");
|
|
350
|
+
if (domCompositionScope) entry.compositionScope = domCompositionScope;
|
|
351
|
+
const domSyncOrigin = el.getAttribute("data-sync-origin");
|
|
352
|
+
if (domSyncOrigin) entry.syncOrigin = domSyncOrigin;
|
|
340
353
|
|
|
341
354
|
const domAudioGroup = el.getAttribute("data-audio-group");
|
|
342
355
|
if (domAudioGroup) {
|
|
@@ -231,6 +231,15 @@ export function isVideoAudible(opts: {
|
|
|
231
231
|
return opts.tag.toLowerCase() === "video" && !opts.muted;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
export function isAudibleVideoNode(el: Element): boolean {
|
|
235
|
+
if (el.tagName.toLowerCase() !== "video" || el.hasAttribute("muted")) return false;
|
|
236
|
+
return isVideoAudible({
|
|
237
|
+
tag: "video",
|
|
238
|
+
hasAudioAttr: el.getAttribute("data-has-audio"),
|
|
239
|
+
muted: false,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
234
243
|
/** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
|
|
235
244
|
function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
236
245
|
const media = resolveMediaElement(el) ?? el;
|
|
@@ -83,6 +83,9 @@ export interface TimelineElement {
|
|
|
83
83
|
/** The owning group's serialized `data-fx-chain`, when set — resolved once per parse. */
|
|
84
84
|
audioGroupFxChain?: string;
|
|
85
85
|
audioGroupAutomation?: string;
|
|
86
|
+
link?: string;
|
|
87
|
+
compositionScope?: string;
|
|
88
|
+
syncOrigin?: string;
|
|
86
89
|
/**
|
|
87
90
|
* Master start of the composition this row runs in, which its tweens and its
|
|
88
91
|
* `data-start` are local to; 0 at the root. Writes go through toAuthoredStart.
|
|
@@ -116,6 +119,15 @@ export function toCompositionTime(element: RowClock, masterTime: number): number
|
|
|
116
119
|
return masterTime - (element.parentCompositionStart ?? 0);
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
type CompositionScoped = Pick<TimelineElement, "sourceFile" | "compositionScope">;
|
|
123
|
+
|
|
124
|
+
export function sameCompositionScope(a: CompositionScoped, b: CompositionScoped): boolean {
|
|
125
|
+
return (
|
|
126
|
+
(a.sourceFile ?? "") === (b.sourceFile ?? "") &&
|
|
127
|
+
(a.compositionScope ?? "") === (b.compositionScope ?? "")
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
119
131
|
/**
|
|
120
132
|
* The fields `updateElement` may write.
|
|
121
133
|
*
|
|
@@ -296,17 +296,6 @@
|
|
|
296
296
|
transition: none;
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
.timeline-audio-link {
|
|
300
|
-
position: absolute;
|
|
301
|
-
z-index: 4;
|
|
302
|
-
top: -9px;
|
|
303
|
-
left: 50%;
|
|
304
|
-
width: 1px;
|
|
305
|
-
height: 10px;
|
|
306
|
-
background: var(--timeline-audio-link);
|
|
307
|
-
pointer-events: none;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
299
|
.timeline-clip.is-hovered {
|
|
311
300
|
background-color: var(--clip-bg-hover);
|
|
312
301
|
}
|
|
@@ -362,6 +351,16 @@
|
|
|
362
351
|
white-space: nowrap;
|
|
363
352
|
}
|
|
364
353
|
|
|
354
|
+
/* Link groups share a label colour on the border and the name chip. */
|
|
355
|
+
.timeline-clip[data-link-color] {
|
|
356
|
+
border-color: var(--clip-link-color);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.timeline-clip[data-link-color] .timeline-clip__label {
|
|
360
|
+
box-shadow: inset 3px 0 0 var(--clip-link-color);
|
|
361
|
+
padding-left: 8px;
|
|
362
|
+
}
|
|
363
|
+
|
|
365
364
|
.timeline-clip.is-audio .timeline-clip__label {
|
|
366
365
|
top: 11px;
|
|
367
366
|
left: 9px;
|
|
@@ -517,6 +516,23 @@
|
|
|
517
516
|
transition: transform 160ms ease;
|
|
518
517
|
}
|
|
519
518
|
|
|
519
|
+
/* The caret of a text edited in place (InlineTextCaret): lit half the time, like the system caret */
|
|
520
|
+
.hf-inline-text-caret {
|
|
521
|
+
animation: hf-inline-text-caret-blink 1.06s step-end infinite;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
@keyframes hf-inline-text-caret-blink {
|
|
525
|
+
50% {
|
|
526
|
+
opacity: 0;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
@media (prefers-reduced-motion: reduce) {
|
|
531
|
+
.hf-inline-text-caret {
|
|
532
|
+
animation: none;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
520
536
|
/* Toast enter/exit (StudioToast) */
|
|
521
537
|
@keyframes hf-toast-in {
|
|
522
538
|
from {
|
package/src/styles/theme.css
CHANGED
|
@@ -117,7 +117,6 @@
|
|
|
117
117
|
--timeline-clip-audio-bg-hover: oklch(0.49 0.12 300);
|
|
118
118
|
--timeline-clip-audio-bg-dragging: oklch(0.42 0.12 300 / 0.96);
|
|
119
119
|
--timeline-clip-audio-muted-bg: rgb(72, 74, 78);
|
|
120
|
-
--timeline-audio-link: light-dark(#626d66, #a0aaa4);
|
|
121
120
|
--timeline-clip-selection: light-dark(#0f766e, rgba(255, 255, 255, 0.85));
|
|
122
121
|
--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
|
|
123
122
|
--timeline-clip-radius: 8px;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
AudioMeterStrip,
|
|
7
7
|
TimelineHistoryButtons,
|
|
8
8
|
TimelineToolbar,
|
|
9
|
+
useAudioMetersVisible as exportedAudioMetersVisible,
|
|
9
10
|
useClipboard,
|
|
10
11
|
usePlayerStore,
|
|
11
12
|
type UseClipboardOptions,
|
|
@@ -51,6 +52,12 @@ describe("timeline chrome package exports, outside Studio's shell", () => {
|
|
|
51
52
|
await act(async () => root.unmount());
|
|
52
53
|
});
|
|
53
54
|
|
|
55
|
+
it("exports the meter visibility store a host toggles", () => {
|
|
56
|
+
expect(exportedAudioMetersVisible).toBe(useAudioMetersVisible);
|
|
57
|
+
exportedAudioMetersVisible.getState().setVisible(true);
|
|
58
|
+
expect(useAudioMetersVisible.getState().visible).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
54
61
|
it("runs the clipboard with no DOM edit session", async () => {
|
|
55
62
|
const toasts: string[] = [];
|
|
56
63
|
const options: UseClipboardOptions = {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
2
3
|
import {
|
|
4
|
+
hasProjectAudio,
|
|
3
5
|
levelToFraction,
|
|
4
6
|
markFraction,
|
|
5
7
|
SILENT_CHANNEL,
|
|
@@ -42,3 +44,38 @@ describe("stepPair", () => {
|
|
|
42
44
|
expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
|
|
43
45
|
});
|
|
44
46
|
});
|
|
47
|
+
|
|
48
|
+
describe("hasProjectAudio", () => {
|
|
49
|
+
const clip = (overrides: Partial<TimelineElement>): TimelineElement => ({
|
|
50
|
+
id: "c",
|
|
51
|
+
tag: "div",
|
|
52
|
+
start: 0,
|
|
53
|
+
duration: 1,
|
|
54
|
+
track: 0,
|
|
55
|
+
...overrides,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores an audible video left on native output at unity gain", () => {
|
|
59
|
+
expect(hasProjectAudio([clip({ tag: "video", hasAudio: true })])).toBe(false);
|
|
60
|
+
expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, volume: 1 })])).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each<[string, Partial<TimelineElement>]>([
|
|
64
|
+
["an above-unity gain", { volume: 2 }],
|
|
65
|
+
["an fx chain", { fxChain: "[]" }],
|
|
66
|
+
["automation", { automation: "{}" }],
|
|
67
|
+
["a group", { audioGroup: "music" }],
|
|
68
|
+
])("counts an audible video routed through Web Audio by %s", (_, fields) => {
|
|
69
|
+
expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, ...fields })])).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ignores a muted video even when it carries processing", () => {
|
|
73
|
+
expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, muted: true, volume: 2 })])).toBe(
|
|
74
|
+
false,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores a silent video", () => {
|
|
79
|
+
expect(hasProjectAudio([clip({ tag: "video" })])).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { audioDbToGain, audioGainToDb } from "@hyperframes/core/audio-gain";
|
|
3
|
+
import { audibleVideoNeedsWebAudio } from "@hyperframes/core/audible-video";
|
|
3
4
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
5
|
import { isAudioTimelineElement } from "./timelineInspector";
|
|
5
6
|
|
|
@@ -67,9 +68,14 @@ export function stepPair(
|
|
|
67
68
|
return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
/** The meters appear only for a project with
|
|
71
|
-
function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
|
|
72
|
-
return elements.some(
|
|
71
|
+
/** The meters appear only for a project with an audio clip, a group, or a video Web Audio carries. */
|
|
72
|
+
export function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
|
|
73
|
+
return elements.some(
|
|
74
|
+
(el) =>
|
|
75
|
+
isAudioTimelineElement(el) ||
|
|
76
|
+
el.audioGroup !== undefined ||
|
|
77
|
+
(el.hasAudio === true && !el.muted && audibleVideoNeedsWebAudio(el)),
|
|
78
|
+
);
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
/** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EXPORT_PEAK_CEILING = 10 ** (-1 / 20);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
4
4
|
import {
|
|
5
|
+
readNestedFiles,
|
|
5
6
|
applySoftReload,
|
|
6
7
|
applySoftReloadFinalization,
|
|
7
8
|
ensureMotionPathPluginLoaded,
|
|
@@ -131,10 +132,14 @@ describe("applySoftReload", () => {
|
|
|
131
132
|
const container = document.createElement("div");
|
|
132
133
|
container.appendChild(scriptEl);
|
|
133
134
|
|
|
135
|
+
const root = document.createElement("div");
|
|
136
|
+
root.setAttribute("data-composition-id", "root");
|
|
137
|
+
root.appendChild(orphan);
|
|
138
|
+
|
|
134
139
|
const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
|
|
135
140
|
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
|
136
141
|
querySelectorAll: (sel: string) =>
|
|
137
|
-
sel === "script:not([src])" ? [scriptEl] : sel
|
|
142
|
+
sel === "script:not([src])" ? [scriptEl] : sel.includes("composition-id") ? [root] : [],
|
|
138
143
|
createElement: (tag: string) => document.createElement(tag),
|
|
139
144
|
body: container,
|
|
140
145
|
head: document.createElement("div"),
|
|
@@ -427,6 +432,8 @@ describe("ensureMotionPathPluginLoaded", () => {
|
|
|
427
432
|
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
|
|
428
433
|
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
|
|
429
434
|
describe("applySoftReload authored-style restore", () => {
|
|
435
|
+
afterEach(() => document.body.replaceChildren());
|
|
436
|
+
|
|
430
437
|
function buildIframeWithTarget(el: Element, overrides: Record<string, unknown> = {}) {
|
|
431
438
|
const scriptEl = document.createElement("script");
|
|
432
439
|
scriptEl.textContent =
|
|
@@ -581,4 +588,233 @@ describe("applySoftReload authored-style restore", () => {
|
|
|
581
588
|
applySoftReload(iframe, SCRIPT_TEXT);
|
|
582
589
|
expect(transformAtRebind).toBe("");
|
|
583
590
|
});
|
|
591
|
+
|
|
592
|
+
// The preview inlines a sub-composition into a host that names its file.
|
|
593
|
+
function inlinedIn(file: string, el: HTMLElement): HTMLElement {
|
|
594
|
+
const host = document.createElement("div");
|
|
595
|
+
host.setAttribute("data-composition-file", file);
|
|
596
|
+
host.appendChild(el);
|
|
597
|
+
document.body.appendChild(host);
|
|
598
|
+
return el;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function answerQueries(iframe: HTMLIFrameElement, styled: Element, root: Element): void {
|
|
602
|
+
const doc = iframe.contentDocument as unknown as { querySelectorAll: (s: string) => unknown };
|
|
603
|
+
const query = doc.querySelectorAll;
|
|
604
|
+
doc.querySelectorAll = (sel: string) =>
|
|
605
|
+
sel.includes("transform") ? [styled] : sel.includes("composition-id") ? [root] : query(sel);
|
|
606
|
+
}
|
|
607
|
+
const SUB_FILE = `<template id="sub-template"><div id="sub" data-composition-id="sub">
|
|
608
|
+
<div id="nroot" data-hf-id="hf-n" style="left: 560px; top: 300px"></div></div></template>`;
|
|
609
|
+
|
|
610
|
+
it("gives a root tween's nested target what its own file authors for what GSAP wrote", () => {
|
|
611
|
+
const el = document.createElement("div");
|
|
612
|
+
el.id = "nroot";
|
|
613
|
+
el.setAttribute("data-hf-id", "hf-n");
|
|
614
|
+
// GSAP folded the stylesheet translate into its transform, masked it, and wrote the tweened width.
|
|
615
|
+
el.style.cssText =
|
|
616
|
+
"left: 560px; top: 300px; width: 337px; translate: none; transform: translate(12.5px, 0px)";
|
|
617
|
+
inlinedIn("compositions/sub.html", el);
|
|
618
|
+
const tween = { targets: () => [el], vars: { x: 50, width: 400, duration: 4 } };
|
|
619
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
620
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
621
|
+
});
|
|
622
|
+
const authoredHtml = `<html><body><div id="root"></div></body></html>`;
|
|
623
|
+
const nestedFiles = new Map([["compositions/sub.html", SUB_FILE]]);
|
|
624
|
+
|
|
625
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml, nestedFiles })).toBe("applied");
|
|
626
|
+
expect(el.getAttribute("style")).toBe("left: 560px; top: 300px;");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("puts back a root element's tweened width from the file instead of the live value", () => {
|
|
630
|
+
const el = document.createElement("div");
|
|
631
|
+
el.setAttribute("data-hf-id", "hf-1");
|
|
632
|
+
el.style.cssText = "left: 700px; width: 366px";
|
|
633
|
+
const tween = { targets: () => [el], vars: { width: 450, duration: 4 } };
|
|
634
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
635
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
636
|
+
});
|
|
637
|
+
const authoredHtml = `<html><body><div data-hf-id="hf-1" style="left: 700px"></div></body></html>`;
|
|
638
|
+
|
|
639
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied");
|
|
640
|
+
expect(el.getAttribute("style")).toBe("left: 700px;");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("leaves what a nested composition's own timeline animates to that timeline", () => {
|
|
644
|
+
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
|
|
645
|
+
el.id = "nroot";
|
|
646
|
+
el.style.cssText = "translate: none; transform: translate(40px, 10px)";
|
|
647
|
+
const sub = { getChildren: () => [{ targets: () => [el], vars: { x: 100 } }] };
|
|
648
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
649
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [sub] }, sub },
|
|
650
|
+
});
|
|
651
|
+
const readFile = vi.fn(async () => SUB_FILE);
|
|
652
|
+
|
|
653
|
+
expect(readNestedFiles(iframe, SCRIPT_TEXT, readFile)).toBeNull();
|
|
654
|
+
applySoftReload(iframe, SCRIPT_TEXT);
|
|
655
|
+
|
|
656
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
657
|
+
expect(el.style.cssText).toBe("translate: none; transform: translate(40px, 10px);");
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("a nested composition's reload leaves a GSAP transform in the top-level file alone", () => {
|
|
661
|
+
const host = document.createElement("div");
|
|
662
|
+
host.setAttribute("data-composition-id", "root");
|
|
663
|
+
host.setAttribute("data-composition-file", "compositions/sub.html");
|
|
664
|
+
const outside = Object.assign(document.createElement("div"), { _gsap: {} });
|
|
665
|
+
outside.style.cssText = "rotate: none; transform: rotate(30deg)";
|
|
666
|
+
const { iframe } = buildIframeWithTarget(host, {
|
|
667
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [] } },
|
|
668
|
+
});
|
|
669
|
+
answerQueries(iframe, outside, host);
|
|
670
|
+
|
|
671
|
+
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: SUB_FILE });
|
|
672
|
+
|
|
673
|
+
expect(outside.style.cssText).toBe("rotate: none; transform: rotate(30deg);");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("a nested composition's reload leaves its element that the top-level timeline animates alone", () => {
|
|
677
|
+
const host = document.createElement("div");
|
|
678
|
+
host.setAttribute("data-composition-id", "root");
|
|
679
|
+
host.setAttribute("data-composition-file", "compositions/sub.html");
|
|
680
|
+
const el = Object.assign(document.createElement("div"), { _gsap: {} });
|
|
681
|
+
// The top-level tween folded the stylesheet translate into its transform.
|
|
682
|
+
el.style.cssText = "translate: none; transform: translate(23.75px, 5px)";
|
|
683
|
+
host.appendChild(el);
|
|
684
|
+
const main = { getChildren: () => [{ targets: () => [el], vars: { x: 50 } }] };
|
|
685
|
+
const { iframe } = buildIframeWithTarget(host, {
|
|
686
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [] }, main },
|
|
687
|
+
});
|
|
688
|
+
answerQueries(iframe, el, host);
|
|
689
|
+
|
|
690
|
+
applySoftReload(iframe, SCRIPT_TEXT, {
|
|
691
|
+
authoredHtml: SUB_FILE,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
expect(el.style.cssText).toBe("translate: none; transform: translate(23.75px, 5px);");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("a nested composition's own reload restores its element from the file just written", () => {
|
|
698
|
+
const host = document.createElement("div");
|
|
699
|
+
host.setAttribute("data-composition-id", "root");
|
|
700
|
+
host.setAttribute("data-composition-file", "compositions/sub.html");
|
|
701
|
+
const el = document.createElement("div");
|
|
702
|
+
el.id = "nroot";
|
|
703
|
+
el.setAttribute("data-hf-id", "hf-n");
|
|
704
|
+
el.style.cssText = "left: 560px; top: 300px; width: 337px";
|
|
705
|
+
host.appendChild(el);
|
|
706
|
+
const tween = { targets: () => [el], vars: { width: 400 } };
|
|
707
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
708
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
709
|
+
});
|
|
710
|
+
answerQueries(iframe, el, host);
|
|
711
|
+
const readFile = vi.fn(async () => SUB_FILE);
|
|
712
|
+
|
|
713
|
+
expect(readNestedFiles(iframe, SCRIPT_TEXT, readFile)).toBeNull();
|
|
714
|
+
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: SUB_FILE });
|
|
715
|
+
|
|
716
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
717
|
+
expect(el.getAttribute("style")).toBe("left: 560px; top: 300px;");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("reads only the other composition files a top-level re-run resets elements of", async () => {
|
|
721
|
+
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
|
|
722
|
+
const tween = { targets: () => [el], vars: { width: 400 } };
|
|
723
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
724
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
725
|
+
});
|
|
726
|
+
const readFile = vi.fn(async () => SUB_FILE);
|
|
727
|
+
|
|
728
|
+
const files = await readNestedFiles(iframe, SCRIPT_TEXT, readFile);
|
|
729
|
+
|
|
730
|
+
expect(readFile).toHaveBeenCalledTimes(1);
|
|
731
|
+
expect(files).toEqual(new Map([["compositions/sub.html", SUB_FILE]]));
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("escalates, touching nothing, when a nested file the reset needs could not be read", () => {
|
|
735
|
+
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
|
|
736
|
+
el.style.cssText = "width: 337px; translate: none";
|
|
737
|
+
const tween = { targets: () => [el], vars: { width: 400 } };
|
|
738
|
+
const set = vi.fn();
|
|
739
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
740
|
+
gsap: { timeline: vi.fn(), set },
|
|
741
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT, { nestedFiles: null })).toBe("cannot-soft-reload");
|
|
745
|
+
expect(set).not.toHaveBeenCalled();
|
|
746
|
+
expect(el.style.cssText).toBe("width: 337px; translate: none;");
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("keeps the runtime's own inline writes: only what GSAP wrote comes back from the file", () => {
|
|
750
|
+
const el = document.createElement("div");
|
|
751
|
+
el.setAttribute("data-hf-id", "hf-1");
|
|
752
|
+
// The runtime's layout pass wrote position and a data-width size; GSAP wrote the mask and transform.
|
|
753
|
+
el.style.cssText =
|
|
754
|
+
"left: 10px; position: absolute; width: 300px; translate: none; transform: translate(50px, 0px)";
|
|
755
|
+
const tween = { targets: () => [el], vars: { x: 50 } };
|
|
756
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
757
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
758
|
+
});
|
|
759
|
+
const authoredHtml = `<html><body><div data-hf-id="hf-1" style="left: 10px"></div></body></html>`;
|
|
760
|
+
|
|
761
|
+
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml });
|
|
762
|
+
|
|
763
|
+
expect(el.getAttribute("style")).toBe("left: 10px; position: absolute; width: 300px;");
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
describe("applySoftReload over a composition's own markup", () => {
|
|
768
|
+
// A live preview whose top-level timeline has no children; `_gsap` marks what a script set.
|
|
769
|
+
function reloadRoot(markup: string, held: string[]) {
|
|
770
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
771
|
+
doc.body.innerHTML = `${markup}<script>${SCRIPT_TEXT}</script>`;
|
|
772
|
+
for (const id of held) Object.assign(doc.getElementById(id)!, { _gsap: {} });
|
|
773
|
+
const set = vi.fn();
|
|
774
|
+
const contentWindow = {
|
|
775
|
+
gsap: { timeline: vi.fn(), set },
|
|
776
|
+
__hfForceTimelineRebind: vi.fn(),
|
|
777
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [] } } as Record<string, unknown>,
|
|
778
|
+
__player: { getTime: () => 0, seek: vi.fn() },
|
|
779
|
+
};
|
|
780
|
+
const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
|
|
781
|
+
const cleared = () => (set.mock.calls[0]?.[0] as Element[] | undefined)?.map((el) => el.id);
|
|
782
|
+
return { doc, iframe, cleared };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
it("resets an element a standalone gsap.set holds even when no inline transform is left", () => {
|
|
786
|
+
const { iframe, cleared } = reloadRoot(
|
|
787
|
+
`<div data-composition-id="root"><div id="held" style="left: 5px"></div><div id="plain"></div></div>`,
|
|
788
|
+
["held"],
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
applySoftReload(iframe, SCRIPT_TEXT);
|
|
792
|
+
|
|
793
|
+
expect(cleared()).toEqual(["held"]);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("leaves what a nested composition's script holds alone, but resets that composition's host", () => {
|
|
797
|
+
const { doc, iframe, cleared } = reloadRoot(
|
|
798
|
+
`<div data-composition-id="root"><div id="host" data-composition-id="sub">` +
|
|
799
|
+
`<div id="nested" style="transform: translate(90px, 60px)"></div></div></div>`,
|
|
800
|
+
["host", "nested"],
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
applySoftReload(iframe, SCRIPT_TEXT);
|
|
804
|
+
|
|
805
|
+
expect(cleared()).toEqual(["host"]);
|
|
806
|
+
expect(doc.getElementById("nested")!.style.transform).toBe("translate(90px, 60px)");
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("reloads in full when the re-run builds its DOM a second time", () => {
|
|
810
|
+
const { doc, iframe } = reloadRoot(`<div id="root" data-composition-id="root"></div>`, []);
|
|
811
|
+
const append = doc.body.appendChild.bind(doc.body);
|
|
812
|
+
// What a script's createElement or template clone does each time it runs.
|
|
813
|
+
doc.body.appendChild = <T extends Node>(node: T): T => {
|
|
814
|
+
doc.getElementById("root")!.appendChild(doc.createElement("div"));
|
|
815
|
+
return append(node);
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
|
819
|
+
});
|
|
584
820
|
});
|