@hyperframes/studio 0.8.106 → 0.8.108
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-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- 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/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- 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/domEditOverlayStartGesture.ts +5 -2
- 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/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.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 +290 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +46 -23
- 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/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
- 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/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -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/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -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 +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- 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.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export const gsapRendersTransform = (el: Element) =>
|
|
2
|
+
!!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Checks whether GSAP actively animates one or more CSS/GSAP properties on
|
|
3
6
|
* the given element by inspecting all registered `__timelines`.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
1
2
|
import { describe, expect, it, vi } from "vitest";
|
|
2
3
|
import { Window } from "happy-dom";
|
|
3
4
|
import {
|
|
@@ -285,7 +286,7 @@ describe("studio manual edits", () => {
|
|
|
285
286
|
expect(set).not.toHaveBeenCalled();
|
|
286
287
|
expect(tickerTick).not.toHaveBeenCalled();
|
|
287
288
|
|
|
288
|
-
beginStudioManualEditGesture(card);
|
|
289
|
+
beginStudioManualEditGesture(card, "move");
|
|
289
290
|
applyStudioPathOffsetDraft(card, { x: 35, y: -6 });
|
|
290
291
|
|
|
291
292
|
expect(readStudioPathOffset(card)).toEqual({ x: 35, y: -6 });
|
|
@@ -395,8 +396,8 @@ describe("studio manual edits", () => {
|
|
|
395
396
|
const card = document.getElementById("card") as HTMLElement;
|
|
396
397
|
|
|
397
398
|
applyStudioPathOffset(card, { x: 40, y: 24 });
|
|
398
|
-
const firstToken = beginStudioManualEditGesture(card);
|
|
399
|
-
const secondToken = beginStudioManualEditGesture(card);
|
|
399
|
+
const firstToken = beginStudioManualEditGesture(card, "move");
|
|
400
|
+
const secondToken = beginStudioManualEditGesture(card, "move");
|
|
400
401
|
endStudioManualEditGesture(card, firstToken);
|
|
401
402
|
|
|
402
403
|
// Gesture still active — offset should remain
|
|
@@ -35,13 +35,26 @@ import {
|
|
|
35
35
|
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
36
36
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
37
37
|
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
|
|
38
|
+
import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";
|
|
38
39
|
|
|
39
40
|
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
|
40
41
|
let studioManualEditGestureId = 0;
|
|
41
42
|
|
|
42
|
-
export
|
|
43
|
+
export type StudioGestureDraws = "move" | "resize" | "rotate" | "edit";
|
|
44
|
+
const MOVE_DRAWS = ["translate", STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP];
|
|
45
|
+
const GESTURE_DRAWS: Record<StudioGestureDraws, readonly string[]> = {
|
|
46
|
+
move: MOVE_DRAWS,
|
|
47
|
+
resize: [...MOVE_DRAWS, STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, ...BOX_SIZE_STYLE_PROPS],
|
|
48
|
+
rotate: ["rotate", "transform", "transform-origin", "display", STUDIO_ROTATION_PROP],
|
|
49
|
+
edit: [],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function beginStudioManualEditGesture(
|
|
53
|
+
element: HTMLElement,
|
|
54
|
+
draws: StudioGestureDraws,
|
|
55
|
+
): string {
|
|
43
56
|
studioManualEditGestureId += 1;
|
|
44
|
-
const token = `gesture-${studioManualEditGestureId}`;
|
|
57
|
+
const token = `gesture-${studioManualEditGestureId}:${draws}`;
|
|
45
58
|
element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token);
|
|
46
59
|
return token;
|
|
47
60
|
}
|
|
@@ -78,10 +91,20 @@ export function studioManualEditSavesIn(doc: Document): number {
|
|
|
78
91
|
return gestureSaves.get(doc) ?? 0;
|
|
79
92
|
}
|
|
80
93
|
|
|
81
|
-
/**
|
|
82
|
-
export function
|
|
94
|
+
/** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
|
|
95
|
+
export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
|
|
83
96
|
const doc = element.ownerDocument;
|
|
84
|
-
gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
|
97
|
+
const count = () => void gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
|
98
|
+
count();
|
|
99
|
+
const result = save();
|
|
100
|
+
void Promise.resolve(result).then(count, count);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function studioGestureDraws(element: Element): readonly string[] | null {
|
|
105
|
+
const token = element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
106
|
+
if (token === null) return null;
|
|
107
|
+
return GESTURE_DRAWS[token.split(":")[1] as StudioGestureDraws] ?? [];
|
|
85
108
|
}
|
|
86
109
|
|
|
87
110
|
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
|
+
import { gsapRendersTransform } from "./gsapAnimatesProperty";
|
|
2
3
|
import {
|
|
3
4
|
STUDIO_OFFSET_X_PROP,
|
|
4
5
|
STUDIO_OFFSET_Y_PROP,
|
|
@@ -127,7 +128,10 @@ const BOX_SIZE_ORIG_ATTRS: ReadonlyArray<[string, string]> = [
|
|
|
127
128
|
export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] {
|
|
128
129
|
const ops: PatchOperation[] = [];
|
|
129
130
|
collectInlineStyleOps(element, [STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP], ops);
|
|
130
|
-
|
|
131
|
+
const withoutGsapScaleMask = gsapRendersTransform(element)
|
|
132
|
+
? BOX_SIZE_STYLE_PROPS.filter((prop) => prop !== "scale")
|
|
133
|
+
: BOX_SIZE_STYLE_PROPS;
|
|
134
|
+
collectInlineStyleOps(element, withoutGsapScaleMask, ops);
|
|
131
135
|
ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" });
|
|
132
136
|
collectAttributeOps(
|
|
133
137
|
element,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
14
|
import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
15
|
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
|
+
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
16
17
|
|
|
17
18
|
interface OffsetDragGsap {
|
|
18
19
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
@@ -356,7 +357,10 @@ export function createManualOffsetDragMember(input: {
|
|
|
356
357
|
}
|
|
357
358
|
|
|
358
359
|
const initialPathOffset = captureStudioPathOffset(input.element);
|
|
359
|
-
const gestureToken = beginStudioManualEditGesture(
|
|
360
|
+
const gestureToken = beginStudioManualEditGesture(
|
|
361
|
+
input.element,
|
|
362
|
+
input.gesture === "resize" ? "resize" : "move",
|
|
363
|
+
);
|
|
360
364
|
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
|
|
361
365
|
scaleX: input.rect.editScaleX,
|
|
362
366
|
scaleY: input.rect.editScaleY,
|
|
@@ -507,6 +511,16 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
|
507
511
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
508
512
|
}
|
|
509
513
|
|
|
514
|
+
/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
|
|
515
|
+
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
|
|
516
|
+
if (!members.every((member) => member.plainTranslate)) return null;
|
|
517
|
+
return () => {
|
|
518
|
+
const shown = members.map((member) => captureStudioPathOffset(member.element));
|
|
519
|
+
for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
520
|
+
return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
510
524
|
/** Roll back a FAILED drag to the exact gesture-start state. */
|
|
511
525
|
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
512
526
|
for (const member of members) {
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { applyPatchByTarget } from "../../utils/sourcePatcher";
|
|
4
|
+
import {
|
|
5
|
+
buildKeepSoundCutoutEdit,
|
|
6
|
+
cutoutOps,
|
|
7
|
+
hasAudioToggleOps,
|
|
8
|
+
mintLinkId,
|
|
9
|
+
mutedToggleOps,
|
|
10
|
+
} from "./mediaAudioEdits";
|
|
11
|
+
|
|
12
|
+
const attr = (property: string, value: string | null) => ({
|
|
13
|
+
type: "attribute" as const,
|
|
14
|
+
property,
|
|
15
|
+
value,
|
|
16
|
+
});
|
|
17
|
+
const html = (property: string, value: string | null) => ({
|
|
18
|
+
type: "html-attribute" as const,
|
|
19
|
+
property,
|
|
20
|
+
value,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("mutedToggleOps", () => {
|
|
24
|
+
it("mutes a video and removes has-audio instead of writing an empty marker", () => {
|
|
25
|
+
expect(mutedToggleOps({ isVideo: true, nextMuted: true, probedHasAudio: null })).toEqual([
|
|
26
|
+
html("muted", "true"),
|
|
27
|
+
attr("has-audio", null),
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("unmutes a video the probe hears as audible", () => {
|
|
32
|
+
expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: true })).toEqual([
|
|
33
|
+
html("muted", null),
|
|
34
|
+
attr("has-audio", "true"),
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("unmutes a silent-probe video with an explicit has-audio=false", () => {
|
|
39
|
+
expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: false })).toEqual([
|
|
40
|
+
html("muted", null),
|
|
41
|
+
attr("has-audio", "false"),
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("leaves has-audio absent when the probe fails", () => {
|
|
46
|
+
expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: null })).toEqual([
|
|
47
|
+
html("muted", null),
|
|
48
|
+
attr("has-audio", null),
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("toggles only muted on an audio element", () => {
|
|
53
|
+
expect(mutedToggleOps({ isVideo: false, nextMuted: true, probedHasAudio: null })).toEqual([
|
|
54
|
+
html("muted", "true"),
|
|
55
|
+
]);
|
|
56
|
+
expect(mutedToggleOps({ isVideo: false, nextMuted: false, probedHasAudio: null })).toEqual([
|
|
57
|
+
html("muted", null),
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("hasAudioToggleOps", () => {
|
|
63
|
+
it("turns sound on by stamping has-audio and clearing muted", () => {
|
|
64
|
+
expect(hasAudioToggleOps(true)).toEqual([attr("has-audio", "true"), html("muted", null)]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("turns sound off by removing has-audio rather than writing an empty value", () => {
|
|
68
|
+
expect(hasAudioToggleOps(false)).toEqual([attr("has-audio", null), html("muted", "true")]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("cutoutOps", () => {
|
|
73
|
+
it("swaps the src of an image and nothing else", () => {
|
|
74
|
+
expect(cutoutOps({ isVideo: false, cutoutSrc: "assets/cut.png" })).toEqual([
|
|
75
|
+
html("src", "assets/cut.png"),
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("swaps a silent video's src and keeps it muted without a has-audio marker", () => {
|
|
80
|
+
expect(cutoutOps({ isVideo: true, cutoutSrc: "assets/cut.webm" })).toEqual([
|
|
81
|
+
html("src", "assets/cut.webm"),
|
|
82
|
+
html("muted", "true"),
|
|
83
|
+
attr("has-audio", null),
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("mintLinkId", () => {
|
|
89
|
+
it("dedupes against element ids and existing links", () => {
|
|
90
|
+
const doc = document.implementation.createHTMLDocument("t");
|
|
91
|
+
expect(mintLinkId(doc)).toBe("link");
|
|
92
|
+
doc.body.innerHTML = '<div id="link"></div><video data-link="link-2"></video>';
|
|
93
|
+
expect(mintLinkId(doc)).toBe("link-3");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function makeVideo(attrs: Record<string, string>): HTMLVideoElement {
|
|
98
|
+
const doc = document.implementation.createHTMLDocument("t");
|
|
99
|
+
const video = doc.createElement("video");
|
|
100
|
+
for (const [name, value] of Object.entries(attrs)) video.setAttribute(name, value);
|
|
101
|
+
doc.body.append(video);
|
|
102
|
+
return video;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function applyEdit(source: string, edit: ReturnType<typeof buildKeepSoundCutoutEdit>): string {
|
|
106
|
+
const target = { id: "clip" };
|
|
107
|
+
const patched = edit.ops.reduce((acc, op) => applyPatchByTarget(acc, target, op), source);
|
|
108
|
+
return edit.prepareContent(patched);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parse(source: string): Document {
|
|
112
|
+
return new DOMParser().parseFromString(source, "text/html");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("buildKeepSoundCutoutEdit", () => {
|
|
116
|
+
const automation = JSON.stringify({
|
|
117
|
+
version: 1,
|
|
118
|
+
lanes: [
|
|
119
|
+
{ target: "volume", points: [{ t: 0, v: 1 }] },
|
|
120
|
+
{ target: "rate", points: [{ t: 0, v: 2 }] },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const source = [
|
|
125
|
+
'<div data-composition-id="main" data-duration="10">',
|
|
126
|
+
' <video id="clip" class="clip" src="assets/talk.mp4" data-start="1" data-duration="4" data-media-start="2" data-track-index="0" data-has-audio="true" data-volume="0.5" data-fade-in="0.3" data-audio-group="voiceover"></video>',
|
|
127
|
+
' <audio id="music" class="clip" src="assets/bgm.mp3" data-start="0" data-duration="10" data-track-index="1"></audio>',
|
|
128
|
+
"</div>",
|
|
129
|
+
].join("\n");
|
|
130
|
+
|
|
131
|
+
const liveVideo = () =>
|
|
132
|
+
makeVideo({
|
|
133
|
+
id: "clip",
|
|
134
|
+
src: "assets/talk.mp4",
|
|
135
|
+
"data-has-audio": "true",
|
|
136
|
+
"data-volume": "0.5",
|
|
137
|
+
"data-fade-in": "0.3",
|
|
138
|
+
"data-audio-group": "voiceover",
|
|
139
|
+
"data-automation": automation,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("mutes the video onto the cutout and moves its sound to a linked sibling audio", () => {
|
|
143
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
144
|
+
video: liveVideo(),
|
|
145
|
+
videoId: "clip",
|
|
146
|
+
target: { id: "clip" },
|
|
147
|
+
cutoutSrc: "assets/talk-cutout.webm",
|
|
148
|
+
});
|
|
149
|
+
const doc = parse(applyEdit(source, edit));
|
|
150
|
+
const video = doc.getElementById("clip");
|
|
151
|
+
const audio = doc.getElementById("clip-audio");
|
|
152
|
+
if (!video || !audio) throw new Error("expected both clips");
|
|
153
|
+
|
|
154
|
+
expect(video.getAttribute("src")).toBe("assets/talk-cutout.webm");
|
|
155
|
+
expect(video.hasAttribute("muted")).toBe(true);
|
|
156
|
+
expect(video.hasAttribute("data-has-audio")).toBe(false);
|
|
157
|
+
expect(video.hasAttribute("data-volume")).toBe(false);
|
|
158
|
+
expect(video.hasAttribute("data-fade-in")).toBe(false);
|
|
159
|
+
expect(video.hasAttribute("data-audio-group")).toBe(false);
|
|
160
|
+
expect(JSON.parse(video.getAttribute("data-automation") ?? "").lanes).toEqual([
|
|
161
|
+
{ target: "rate", points: [{ t: 0, v: 2 }] },
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
expect(audio.tagName).toBe("AUDIO");
|
|
165
|
+
expect(audio.getAttribute("src")).toBe("assets/talk.mp4");
|
|
166
|
+
expect(audio.getAttribute("data-start")).toBe("1");
|
|
167
|
+
expect(audio.getAttribute("data-duration")).toBe("4");
|
|
168
|
+
expect(audio.getAttribute("data-media-start")).toBe("2");
|
|
169
|
+
expect(audio.hasAttribute("data-playback-rate")).toBe(false);
|
|
170
|
+
expect(audio.getAttribute("data-track-index")).toBe("2");
|
|
171
|
+
expect(audio.getAttribute("data-volume")).toBe("0.5");
|
|
172
|
+
expect(audio.getAttribute("data-fade-in")).toBe("0.3");
|
|
173
|
+
expect(audio.getAttribute("data-audio-group")).toBe("voiceover");
|
|
174
|
+
expect(JSON.parse(audio.getAttribute("data-automation") ?? "").lanes).toEqual([
|
|
175
|
+
{ target: "volume", points: [{ t: 0, v: 1 }] },
|
|
176
|
+
{ target: "rate", points: [{ t: 0, v: 2 }] },
|
|
177
|
+
]);
|
|
178
|
+
expect(audio.getAttribute("data-link")).toBe(video.getAttribute("data-link"));
|
|
179
|
+
expect(video.getAttribute("data-link")).toBe("link");
|
|
180
|
+
expect(audio.parentElement).toBe(video.parentElement);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("drops the video's automation attribute when it held no rate lane", () => {
|
|
184
|
+
const video = makeVideo({
|
|
185
|
+
id: "clip",
|
|
186
|
+
src: "assets/talk.mp4",
|
|
187
|
+
"data-automation": JSON.stringify({
|
|
188
|
+
version: 1,
|
|
189
|
+
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
193
|
+
video,
|
|
194
|
+
videoId: "clip",
|
|
195
|
+
target: { id: "clip" },
|
|
196
|
+
cutoutSrc: "assets/cut.webm",
|
|
197
|
+
});
|
|
198
|
+
expect(edit.ops).toContainEqual(attr("automation", null));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("suffixes the audio id when <video-id>-audio is taken", () => {
|
|
202
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
203
|
+
video: liveVideo(),
|
|
204
|
+
videoId: "clip",
|
|
205
|
+
target: { id: "clip" },
|
|
206
|
+
cutoutSrc: "assets/cut.webm",
|
|
207
|
+
});
|
|
208
|
+
const taken = source.replace('id="music"', 'id="clip-audio"');
|
|
209
|
+
const doc = parse(applyEdit(taken, edit));
|
|
210
|
+
expect(doc.getElementById("clip-audio-2")?.getAttribute("src")).toBe("assets/talk.mp4");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("reads the authored src from the source, not the live element", () => {
|
|
214
|
+
const sub = source.replace('src="assets/talk.mp4"', 'src="../assets/a.mp4"');
|
|
215
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
216
|
+
video: liveVideo(),
|
|
217
|
+
videoId: "clip",
|
|
218
|
+
target: { id: "clip" },
|
|
219
|
+
cutoutSrc: "assets/cut.webm",
|
|
220
|
+
});
|
|
221
|
+
const doc = parse(applyEdit(sub, edit));
|
|
222
|
+
expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("../assets/a.mp4");
|
|
223
|
+
expect(doc.getElementById("clip")?.getAttribute("src")).toBe("assets/cut.webm");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("takes the first <source> child when the video has no src attribute", () => {
|
|
227
|
+
const withSource = source.replace(
|
|
228
|
+
/<video id="clip"([^>]*?) src="assets\/talk.mp4"([^>]*)><\/video>/,
|
|
229
|
+
'<video id="clip"$1$2><source src="assets/s.mp4" type="video/mp4"></video>',
|
|
230
|
+
);
|
|
231
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
232
|
+
video: liveVideo(),
|
|
233
|
+
videoId: "clip",
|
|
234
|
+
target: { id: "clip" },
|
|
235
|
+
cutoutSrc: "assets/cut.webm",
|
|
236
|
+
});
|
|
237
|
+
const doc = parse(applyEdit(withSource, edit));
|
|
238
|
+
expect(doc.getElementById("clip-audio")?.getAttribute("src")).toBe("assets/s.mp4");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reports when the audio insert did not land", () => {
|
|
242
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
243
|
+
video: liveVideo(),
|
|
244
|
+
videoId: "clip",
|
|
245
|
+
target: { id: "missing" },
|
|
246
|
+
cutoutSrc: "assets/cut.webm",
|
|
247
|
+
});
|
|
248
|
+
edit.prepareContent(source);
|
|
249
|
+
expect(edit.audioInserted()).toBe(false);
|
|
250
|
+
const ok = buildKeepSoundCutoutEdit({
|
|
251
|
+
video: liveVideo(),
|
|
252
|
+
videoId: "clip",
|
|
253
|
+
target: { id: "clip" },
|
|
254
|
+
cutoutSrc: "assets/cut.webm",
|
|
255
|
+
});
|
|
256
|
+
ok.prepareContent(source);
|
|
257
|
+
expect(ok.audioInserted()).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("copies loop onto the linked audio", () => {
|
|
261
|
+
const video = makeVideo({ id: "clip", src: "assets/talk.mp4", loop: "" });
|
|
262
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
263
|
+
video,
|
|
264
|
+
videoId: "clip",
|
|
265
|
+
target: { id: "clip" },
|
|
266
|
+
cutoutSrc: "assets/cut.webm",
|
|
267
|
+
});
|
|
268
|
+
const doc = parse(applyEdit(source, edit));
|
|
269
|
+
expect(doc.getElementById("clip-audio")?.hasAttribute("loop")).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
});
|