@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
|
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
|
|
7
7
|
import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
|
|
8
|
+
import { applyUndoRestoreToPreview } from "../utils/gsapUndoRestore";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
@@ -643,3 +644,29 @@ describe("useInlineTextEdit with styled runs", () => {
|
|
|
643
644
|
act(() => root.unmount());
|
|
644
645
|
});
|
|
645
646
|
});
|
|
647
|
+
|
|
648
|
+
describe("an undo that lands while a text edit is open", () => {
|
|
649
|
+
it("marks the edit as drawing nothing, so the undo reverts the move and keeps the edit", () => {
|
|
650
|
+
const wrap = (body: string) => `<html><body>${body}</body></html>`;
|
|
651
|
+
const moved = `<h1 id="t" style="translate: 90px 60px">Title</h1>`;
|
|
652
|
+
document.body.innerHTML = moved;
|
|
653
|
+
const element = document.getElementById("t")!;
|
|
654
|
+
const { controls, root } = mount();
|
|
655
|
+
act(() => {
|
|
656
|
+
controls().start(element);
|
|
657
|
+
});
|
|
658
|
+
const contentWindow = { __player: { seek: vi.fn() }, __hfStudioManualEditsApply: vi.fn() };
|
|
659
|
+
const iframe = { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement;
|
|
660
|
+
const files = {
|
|
661
|
+
"index.html": { previous: wrap(moved), restored: wrap(`<h1 id="t">Title</h1>`) },
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
expect(element.getAttribute("data-hf-studio-manual-edit-gesture")).toMatch(/:edit$/);
|
|
665
|
+
expect(applyUndoRestoreToPreview(iframe, "index.html", files, 0, vi.fn())).toBe("soft");
|
|
666
|
+
|
|
667
|
+
expect(element.style.getPropertyValue("translate")).toBe("");
|
|
668
|
+
expect(element.getAttribute("contenteditable")).toBe("true");
|
|
669
|
+
expect(element.style.getPropertyValue("outline")).not.toBe("");
|
|
670
|
+
act(() => root.unmount());
|
|
671
|
+
});
|
|
672
|
+
});
|
|
@@ -5,7 +5,7 @@ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
|
5
5
|
import {
|
|
6
6
|
beginStudioManualEditGesture,
|
|
7
7
|
endStudioManualEditGesture,
|
|
8
|
-
|
|
8
|
+
countStudioManualEditSave,
|
|
9
9
|
} from "../components/editor/manualEditsDom";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -141,7 +141,7 @@ export function useInlineTextEdit({
|
|
|
141
141
|
original: element.innerHTML,
|
|
142
142
|
outline: element.style.outline,
|
|
143
143
|
outlineOffset: element.style.outlineOffset,
|
|
144
|
-
gesture: beginStudioManualEditGesture(element),
|
|
144
|
+
gesture: beginStudioManualEditGesture(element, "edit"),
|
|
145
145
|
};
|
|
146
146
|
// Drawn on the element itself, not in Studio's overlay above it. This is
|
|
147
147
|
// the only mark that says the caret is in the TEXT rather than the
|
|
@@ -181,11 +181,13 @@ export function useInlineTextEdit({
|
|
|
181
181
|
// Counted and pending before the edit closes, so a reload waits for the save, not the mark.
|
|
182
182
|
let save: (saving: unknown) => void = () => {};
|
|
183
183
|
trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
184
|
+
const commitText = () => {
|
|
185
|
+
teardown();
|
|
186
|
+
// After teardown, so the commit path's own resync does not fight an
|
|
187
|
+
// element that is still editable.
|
|
188
|
+
return onCommit({ element: open.element, html, previousHtml: open.original });
|
|
189
|
+
};
|
|
190
|
+
save(countStudioManualEditSave(open.element, commitText));
|
|
189
191
|
}, [onCommit, teardown]);
|
|
190
192
|
|
|
191
193
|
const cancel = useCallback(() => {
|
|
@@ -86,6 +86,21 @@ describe("keyframe shortcuts", () => {
|
|
|
86
86
|
window.removeEventListener("keydown", playback);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
describe("with a focused picker", () => {
|
|
90
|
+
const picker = document.createElement("button");
|
|
91
|
+
picker.setAttribute("role", "combobox");
|
|
92
|
+
afterEach(() => picker.remove());
|
|
93
|
+
|
|
94
|
+
it("leaves K to a focused picker that owns typing, like a font combobox", () => {
|
|
95
|
+
const onAddKeyframe = mountKeyframeKeyboard(true);
|
|
96
|
+
document.body.append(picker);
|
|
97
|
+
picker.focus();
|
|
98
|
+
|
|
99
|
+
expect(press("k").defaultPrevented).toBe(false);
|
|
100
|
+
expect(onAddKeyframe).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
89
104
|
it("K stays playback's stop key when no keyframeable element is selected", () => {
|
|
90
105
|
const playback = vi.fn();
|
|
91
106
|
window.addEventListener("keydown", playback);
|
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
import { useEffect, useCallback } from "react";
|
|
2
2
|
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
3
|
+
import { isTypingTarget } from "../utils/typingTarget";
|
|
3
4
|
|
|
4
5
|
interface KeyframeKeyboardOptions {
|
|
5
6
|
enabled: boolean;
|
|
6
7
|
onAddKeyframe?: () => void;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
function isTextInput(el: Element | null): boolean {
|
|
10
|
-
if (!el) return false;
|
|
11
|
-
const tag = el.tagName;
|
|
12
|
-
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
13
|
-
return (el as HTMLElement).isContentEditable === true;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
10
|
export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
|
|
17
11
|
const handler = useCallback(
|
|
18
12
|
(e: KeyboardEvent) => {
|
|
19
13
|
if (!enabled || !onAddKeyframe) return;
|
|
20
|
-
if (
|
|
14
|
+
if (isTypingTarget(document.activeElement)) return;
|
|
21
15
|
if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
|
|
22
16
|
if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
|
|
23
17
|
// K is also playback's stop key: claiming it here keeps playback from also pausing.
|
|
@@ -243,8 +243,14 @@ it("a step that cannot reach the server says so", async () => {
|
|
|
243
243
|
});
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
-
it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
|
|
246
|
+
it("predicts a step from what this tab wrote as soon as its save ends, and not while a claim or step may have moved the history", async () => {
|
|
247
247
|
const { hook, save, readFile } = await studio();
|
|
248
|
+
const real = globalThis.fetch;
|
|
249
|
+
const slowView = (url: string, init?: RequestInit) =>
|
|
250
|
+
url.endsWith("/history")
|
|
251
|
+
? new Promise((r) => setTimeout(r, 50)).then(() => real(url, init))
|
|
252
|
+
: real(url, init);
|
|
253
|
+
vi.stubGlobal("fetch", slowView);
|
|
248
254
|
save("B");
|
|
249
255
|
const claim = hook().recordEdit({
|
|
250
256
|
label: "Moved Title",
|
|
@@ -252,11 +258,8 @@ it("predicts a step from what this tab wrote, and not while a claim or step may
|
|
|
252
258
|
});
|
|
253
259
|
expect(hook().predict("undo")).toBeNull();
|
|
254
260
|
await act(() => claim);
|
|
255
|
-
const predicted =
|
|
256
|
-
|
|
257
|
-
expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
258
|
-
return next!;
|
|
259
|
-
});
|
|
261
|
+
const predicted = hook().predict("undo")!;
|
|
262
|
+
expect(predicted?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
|
|
260
263
|
|
|
261
264
|
hook().noteOutsideChange();
|
|
262
265
|
expect(hook().predict("undo")).toBeNull();
|
|
@@ -223,7 +223,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
223
223
|
const claimed = claimHeld(reply, label);
|
|
224
224
|
if (claimed) own.remember(claimed, files);
|
|
225
225
|
heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
|
|
226
|
-
|
|
226
|
+
await refresh();
|
|
227
227
|
},
|
|
228
228
|
[projectId, refresh, own],
|
|
229
229
|
);
|
|
@@ -159,6 +159,11 @@ export function usePreviewPersistence({
|
|
|
159
159
|
if (result.status !== "clean") throw result.error;
|
|
160
160
|
}, [drainPendingDomEditSaves]);
|
|
161
161
|
|
|
162
|
+
const settlePendingEdits = useCallback(async (): Promise<void> => {
|
|
163
|
+
await flushStudioPendingEdits();
|
|
164
|
+
await domEditSaveQueueRef.current?.waitForIdle();
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
162
167
|
const resetDomEditSaveQueueBreaker = useCallback(() => {
|
|
163
168
|
domEditSaveQueueRef.current?.reset();
|
|
164
169
|
setDomEditSaveQueuePaused(null);
|
|
@@ -253,6 +258,7 @@ export function usePreviewPersistence({
|
|
|
253
258
|
queueDomEditSave,
|
|
254
259
|
drainPendingDomEditSaves,
|
|
255
260
|
waitForPendingDomEditSaves,
|
|
261
|
+
settlePendingEdits,
|
|
256
262
|
domEditSaveQueuePaused,
|
|
257
263
|
resetDomEditSaveQueueBreaker,
|
|
258
264
|
applyCurrentStudioManualEditsToPreview,
|
|
@@ -6,6 +6,7 @@ import { trackStudioRazorSplit } from "../telemetry/events";
|
|
|
6
6
|
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
|
|
7
7
|
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
|
|
8
8
|
import type { RecordEditInput } from "./timelineEditingHelpers";
|
|
9
|
+
import { useFreezeFrame } from "./useFreezeFrame";
|
|
9
10
|
|
|
10
11
|
interface UseRazorSplitOptions {
|
|
11
12
|
projectId: string | null;
|
|
@@ -135,5 +136,17 @@ export function useRazorSplit({
|
|
|
135
136
|
[isRecordingRef, runCut, showToast],
|
|
136
137
|
);
|
|
137
138
|
|
|
138
|
-
|
|
139
|
+
const handleFreezeFrame = useFreezeFrame({
|
|
140
|
+
projectId,
|
|
141
|
+
activeCompPath,
|
|
142
|
+
showToast,
|
|
143
|
+
writeProjectFile,
|
|
144
|
+
observeProjectFileVersion,
|
|
145
|
+
recordEdit,
|
|
146
|
+
reloadPreview,
|
|
147
|
+
forceReloadSdkSession,
|
|
148
|
+
isRecordingRef,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame };
|
|
139
152
|
}
|
|
@@ -5,6 +5,14 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
7
7
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
8
|
+
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
|
|
9
|
+
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
|
|
10
|
+
|
|
11
|
+
function unwrapPeakMarks(node: ReactNode): ReactNode {
|
|
12
|
+
return isValidElement<{ children?: ReactNode }>(node) && node.type === ClipPeakMarks
|
|
13
|
+
? node.props.children
|
|
14
|
+
: node;
|
|
15
|
+
}
|
|
8
16
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
9
17
|
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
|
|
10
18
|
import { buildCompositionThumbnailUrl } from "../player/components/CompositionThumbnail";
|
|
@@ -93,9 +101,51 @@ describe("useRenderClipContent", () => {
|
|
|
93
101
|
root.render(React.createElement(Harness));
|
|
94
102
|
});
|
|
95
103
|
act(() => root.unmount());
|
|
104
|
+
return unwrapPeakMarks(content);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderRaw(el: TimelineElement): ReactNode {
|
|
108
|
+
const host = document.createElement("div");
|
|
109
|
+
document.body.append(host);
|
|
110
|
+
const root = createRoot(host);
|
|
111
|
+
let content: ReactNode = null;
|
|
112
|
+
function Harness() {
|
|
113
|
+
const render = useRenderClipContent({
|
|
114
|
+
projectIdRef: { current: "my-project" },
|
|
115
|
+
compIdToSrc: new Map(),
|
|
116
|
+
activePreviewUrl: null,
|
|
117
|
+
effectiveTimelineDuration: 12,
|
|
118
|
+
});
|
|
119
|
+
content = render(el, { clip: "#222", label: "#fff" });
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
act(() => root.render(React.createElement(Harness)));
|
|
123
|
+
act(() => root.unmount());
|
|
96
124
|
return content;
|
|
97
125
|
}
|
|
98
126
|
|
|
127
|
+
it("wraps a clip's waveform in peak marks at its own gain over its played source window", () => {
|
|
128
|
+
const content = renderRaw({
|
|
129
|
+
id: "voiceover",
|
|
130
|
+
tag: "audio",
|
|
131
|
+
start: 1,
|
|
132
|
+
duration: 4,
|
|
133
|
+
track: 1,
|
|
134
|
+
src: "assets/voiceover.mp3",
|
|
135
|
+
playbackStart: 2,
|
|
136
|
+
playbackRate: 1.5,
|
|
137
|
+
volume: 2,
|
|
138
|
+
});
|
|
139
|
+
expect(isValidElement(content) && content.type).toBe(ClipPeakMarks);
|
|
140
|
+
if (isValidElement<Record<string, unknown>>(content)) {
|
|
141
|
+
expect(content.props).toMatchObject({
|
|
142
|
+
peaksUrl: "/api/projects/my-project/peaks/assets/voiceover.mp3",
|
|
143
|
+
sourceWindow: { mediaStart: 2, sourceSpan: 6 },
|
|
144
|
+
gain: 2,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
99
149
|
it("renders audio clips as waveforms even when a composition preview URL is active", () => {
|
|
100
150
|
const content = renderClipContent({
|
|
101
151
|
id: "voiceover",
|
|
@@ -176,6 +226,58 @@ describe("useRenderClipContent", () => {
|
|
|
176
226
|
}
|
|
177
227
|
});
|
|
178
228
|
|
|
229
|
+
it("draws a waveform strip under the thumbnails of a video with sound", () => {
|
|
230
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
231
|
+
const content = renderClipContent(
|
|
232
|
+
{
|
|
233
|
+
id: "talk",
|
|
234
|
+
tag: "video",
|
|
235
|
+
start: 0,
|
|
236
|
+
duration: 4,
|
|
237
|
+
track: 0,
|
|
238
|
+
src: "talk.mp4",
|
|
239
|
+
hasAudio: true,
|
|
240
|
+
},
|
|
241
|
+
null,
|
|
242
|
+
);
|
|
243
|
+
expect(isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)).toBe(true);
|
|
244
|
+
if (!isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)) return;
|
|
245
|
+
expect(content.type).toBe(AudibleVideoClipContent);
|
|
246
|
+
const { thumbnail } = content.props;
|
|
247
|
+
const waveform = unwrapPeakMarks(content.props.waveform);
|
|
248
|
+
expect(isValidElement(thumbnail) && thumbnail.type).toBe(VideoThumbnail);
|
|
249
|
+
expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe(
|
|
250
|
+
"/api/projects/my-project/waveform/talk.mp4",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("keeps a muted or silent video to thumbnails only", () => {
|
|
255
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
256
|
+
for (const extra of [{ hasAudio: true, muted: true }, { hasAudio: false }]) {
|
|
257
|
+
const content = renderClipContent(
|
|
258
|
+
{ id: "b", tag: "video", start: 0, duration: 4, track: 0, src: "b.mp4", ...extra },
|
|
259
|
+
null,
|
|
260
|
+
);
|
|
261
|
+
expect(isValidElement(content) && content.type).toBe(VideoThumbnail);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("still shows the sound strip when thumbnails are off", () => {
|
|
266
|
+
const content = renderClipContent(
|
|
267
|
+
{
|
|
268
|
+
id: "talk",
|
|
269
|
+
tag: "video",
|
|
270
|
+
start: 0,
|
|
271
|
+
duration: 4,
|
|
272
|
+
track: 0,
|
|
273
|
+
src: "talk.mp4",
|
|
274
|
+
hasAudio: true,
|
|
275
|
+
},
|
|
276
|
+
null,
|
|
277
|
+
);
|
|
278
|
+
expect(isValidElement(content) && content.type).toBe(AudibleVideoClipContent);
|
|
279
|
+
});
|
|
280
|
+
|
|
179
281
|
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
|
180
282
|
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
181
283
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
|
+
import { resolvePreviewRelative } from "../utils/previewRelativePath";
|
|
2
3
|
import { useCallback, type ReactNode } from "react";
|
|
3
4
|
import { createElement } from "react";
|
|
4
5
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
@@ -11,6 +12,9 @@ import type { TimelineClipRenderContext } from "../player/components/TimelineTyp
|
|
|
11
12
|
import { audioPillFlags } from "../player/components/audioClipLink";
|
|
12
13
|
import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
|
|
13
14
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
15
|
+
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
|
|
16
|
+
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
|
|
17
|
+
import { clipHasSound } from "../player/components/clipMenuNormalize";
|
|
14
18
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
15
19
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
16
20
|
import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
|
|
@@ -33,24 +37,6 @@ export function normalizeCompositionSrc(
|
|
|
33
37
|
return compSrc;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/** Resolve a media src to its project-relative preview path, or null. */
|
|
37
|
-
function resolvePreviewRelative(
|
|
38
|
-
src: string | undefined,
|
|
39
|
-
pid: string,
|
|
40
|
-
origin: string,
|
|
41
|
-
): string | null {
|
|
42
|
-
if (!src) return null;
|
|
43
|
-
try {
|
|
44
|
-
const parsed = new URL(src, origin);
|
|
45
|
-
const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
|
|
46
|
-
return parsed.pathname.startsWith(base)
|
|
47
|
-
? decodeURIComponent(parsed.pathname.slice(base.length))
|
|
48
|
-
: null;
|
|
49
|
-
} catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
40
|
/**
|
|
55
41
|
* The trimmed source slice as start/end fractions (0–1) of the source, so the
|
|
56
42
|
* waveform can window its peaks to the clip edges. Undefined when the source
|
|
@@ -88,7 +74,7 @@ function renderAudioClip(
|
|
|
88
74
|
? buildProjectApiPath(pid, `/waveform/${encodedRelative}`)
|
|
89
75
|
: undefined;
|
|
90
76
|
const { start, end } = trimFractions(el);
|
|
91
|
-
|
|
77
|
+
const waveform = createElement(AudioWaveform, {
|
|
92
78
|
audioUrl,
|
|
93
79
|
waveformUrl,
|
|
94
80
|
label: "",
|
|
@@ -100,6 +86,27 @@ function renderAudioClip(
|
|
|
100
86
|
priority: context.priority,
|
|
101
87
|
...audioPillFlags(el, elements),
|
|
102
88
|
});
|
|
89
|
+
return createElement(
|
|
90
|
+
ClipPeakMarks,
|
|
91
|
+
{
|
|
92
|
+
peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined,
|
|
93
|
+
sourceWindow: {
|
|
94
|
+
mediaStart: el.playbackStart ?? 0,
|
|
95
|
+
sourceSpan: el.duration * (el.playbackRate ?? 1),
|
|
96
|
+
},
|
|
97
|
+
gain: el.volume ?? 1,
|
|
98
|
+
},
|
|
99
|
+
waveform,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function withSoundStrip(
|
|
104
|
+
el: TimelineElement,
|
|
105
|
+
thumbnail: ReactNode,
|
|
106
|
+
waveform: () => ReactNode,
|
|
107
|
+
): ReactNode {
|
|
108
|
+
if (el.tag !== "video" || !clipHasSound(el)) return thumbnail;
|
|
109
|
+
return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() });
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
export interface UseRenderClipContentOptions {
|
|
@@ -134,10 +141,9 @@ export function useRenderClipContent({
|
|
|
134
141
|
|
|
135
142
|
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
|
|
136
143
|
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
144
|
+
const waveform = () => renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
|
|
137
145
|
if (effectiveMode === "hidden") {
|
|
138
|
-
return rendersWaveform(el)
|
|
139
|
-
? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
|
|
140
|
-
: null;
|
|
146
|
+
return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform);
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
let compSrc = el.compositionSrc;
|
|
@@ -222,7 +228,7 @@ export function useRenderClipContent({
|
|
|
222
228
|
rich: context.rich,
|
|
223
229
|
});
|
|
224
230
|
}
|
|
225
|
-
|
|
231
|
+
const thumbnail = createElement(VideoThumbnail, {
|
|
226
232
|
videoSrc: mediaSrc,
|
|
227
233
|
label: "",
|
|
228
234
|
labelColor: style.label,
|
|
@@ -233,6 +239,7 @@ export function useRenderClipContent({
|
|
|
233
239
|
sessionEpoch,
|
|
234
240
|
priority: context.priority,
|
|
235
241
|
});
|
|
242
|
+
return withSoundStrip(el, thumbnail, waveform);
|
|
236
243
|
}
|
|
237
244
|
|
|
238
245
|
if (htmlPreviewEligible) {
|
|
@@ -492,8 +492,7 @@ export function useTimelineEditing({
|
|
|
492
492
|
});
|
|
493
493
|
|
|
494
494
|
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
|
|
495
|
-
|
|
496
|
-
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
|
495
|
+
const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
|
|
497
496
|
projectId,
|
|
498
497
|
activeCompPath,
|
|
499
498
|
showToast,
|
|
@@ -575,6 +574,7 @@ export function useTimelineEditing({
|
|
|
575
574
|
),
|
|
576
575
|
handleTimelineElementSplit: trackedRazorSplit,
|
|
577
576
|
handleRazorSplit: trackedRazorSplit,
|
|
577
|
+
handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
|
|
578
578
|
// Same selection the handler itself splits (useRazorSplit.ts).
|
|
579
579
|
handleRazorSplitAll: track(
|
|
580
580
|
guard(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { trackedStudioEdit } from "../utils/studioPendingEdits";
|
|
3
3
|
|
|
4
4
|
type AsyncFn = (...args: never[]) => Promise<unknown>;
|
|
5
5
|
|
|
@@ -11,11 +11,7 @@ export function useTrackPendingTimelineEdit() {
|
|
|
11
11
|
const key = fn as unknown as AsyncFn;
|
|
12
12
|
const cached = wrappedRef.current.get(key);
|
|
13
13
|
if (cached) return cached as unknown as (...args: Args) => Promise<R>;
|
|
14
|
-
const wrapped = (
|
|
15
|
-
const result = fn(...args);
|
|
16
|
-
trackStudioPendingEdit(result);
|
|
17
|
-
return result;
|
|
18
|
-
};
|
|
14
|
+
const wrapped = trackedStudioEdit(fn);
|
|
19
15
|
wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
|
|
20
16
|
return wrapped;
|
|
21
17
|
}, []);
|
package/src/index.ts
CHANGED
|
@@ -175,6 +175,7 @@ export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
|
|
|
175
175
|
export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
|
|
176
176
|
export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
|
|
177
177
|
export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
|
|
178
|
+
export { useAudioMetersVisible } from "./utils/audioMeterVisibility";
|
|
178
179
|
export { useClipboard } from "./hooks/useClipboard";
|
|
179
180
|
export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
180
181
|
|
|
@@ -240,3 +241,10 @@ export type { MarqueeGestures, MarqueeGesturesDeps } from "./components/editor/m
|
|
|
240
241
|
export { MarqueeOverlay } from "./components/editor/MarqueeOverlay";
|
|
241
242
|
export type { MarqueeOverlayProps } from "./components/editor/MarqueeOverlay";
|
|
242
243
|
export type { Rect as MarqueeRect } from "./utils/marqueeGeometry";
|
|
244
|
+
|
|
245
|
+
export { buildStudioTools } from "./webmcp/useStudioAgentTools";
|
|
246
|
+
export type { StudioAgentToolsDeps } from "./webmcp/useStudioAgentTools";
|
|
247
|
+
export { collectStudioLookScene } from "./webmcp/tools/lookTools";
|
|
248
|
+
export type { StudioLookSnapshot } from "./webmcp/tools/lookTools";
|
|
249
|
+
export type { ModelContextTool } from "./webmcp/types";
|
|
250
|
+
export type { ToolResult } from "./webmcp/toolResult";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Share of the clip height the sound strip takes under a video's thumbnails. */
|
|
4
|
+
const AUDIBLE_VIDEO_WAVE_SHARE = 0.38;
|
|
5
|
+
|
|
6
|
+
/** A video that carries sound: thumbnails on top, its waveform strip along the bottom. */
|
|
7
|
+
export function AudibleVideoClipContent({
|
|
8
|
+
thumbnail,
|
|
9
|
+
waveform,
|
|
10
|
+
}: {
|
|
11
|
+
thumbnail: ReactNode;
|
|
12
|
+
waveform: ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
const waveHeight = `${AUDIBLE_VIDEO_WAVE_SHARE * 100}%`;
|
|
15
|
+
return (
|
|
16
|
+
<div className="relative h-full w-full" data-testid="audible-video-clip">
|
|
17
|
+
<div className="absolute inset-x-0 top-0" style={{ bottom: waveHeight }}>
|
|
18
|
+
{thumbnail}
|
|
19
|
+
</div>
|
|
20
|
+
<div
|
|
21
|
+
className="absolute inset-x-0 bottom-0 bg-black/30"
|
|
22
|
+
data-testid="audible-video-wave"
|
|
23
|
+
style={{ height: waveHeight }}
|
|
24
|
+
>
|
|
25
|
+
{waveform}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { usePreviewIframeStore } from "../store/previewIframeStore";
|
|
8
|
+
import { ClipBadges } from "./ClipBadges";
|
|
9
|
+
|
|
10
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let root: Root | null = null;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
act(() => root?.unmount());
|
|
19
|
+
root = null;
|
|
20
|
+
usePreviewIframeStore.getState().setIframe(null);
|
|
21
|
+
document.body.innerHTML = "";
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const talk: TimelineElement = {
|
|
25
|
+
id: "talk",
|
|
26
|
+
domId: "talk",
|
|
27
|
+
tag: "video",
|
|
28
|
+
start: 0,
|
|
29
|
+
duration: 6,
|
|
30
|
+
track: 0,
|
|
31
|
+
hasAudio: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function render(el: TimelineElement) {
|
|
35
|
+
const host = document.createElement("div");
|
|
36
|
+
document.body.append(host);
|
|
37
|
+
root = createRoot(host);
|
|
38
|
+
act(() => root?.render(<ClipBadges el={el} />));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const labels = () =>
|
|
42
|
+
Array.from(document.querySelectorAll("[data-badge]")).map(
|
|
43
|
+
(badge) => `${badge.getAttribute("data-badge")}:${badge.textContent}`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
function mountPreview(html: string): Document {
|
|
47
|
+
const iframe = document.createElement("iframe");
|
|
48
|
+
document.body.append(iframe);
|
|
49
|
+
const doc = iframe.contentDocument;
|
|
50
|
+
if (!doc) throw new Error("no iframe document");
|
|
51
|
+
doc.body.innerHTML = html;
|
|
52
|
+
usePreviewIframeStore.getState().setIframe(iframe);
|
|
53
|
+
return doc;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("ClipBadges", () => {
|
|
57
|
+
it("renders nothing for an untouched clip", () => {
|
|
58
|
+
render(talk);
|
|
59
|
+
expect(document.querySelector("[data-testid='clip-badges']")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls back to the store's volume without a preview", () => {
|
|
63
|
+
render({ ...talk, volume: 1.8 });
|
|
64
|
+
expect(labels()).toEqual(["volume:180%"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reads the live node and follows an agent's attribute edit", async () => {
|
|
68
|
+
const doc = mountPreview(
|
|
69
|
+
`<video id="talk" data-has-audio="true" data-link="talk-pair" style="clip-path: inset(10px)"></video>`,
|
|
70
|
+
);
|
|
71
|
+
render(talk);
|
|
72
|
+
expect(labels()).toEqual(["link:🔗", "crop:Crop"]);
|
|
73
|
+
const node = doc.getElementById("talk");
|
|
74
|
+
await act(async () => {
|
|
75
|
+
node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}');
|
|
76
|
+
node?.setAttribute("data-volume", "0.6");
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
|
+
});
|
|
79
|
+
expect(labels()).toEqual(["link:🔗", "look:Warm daylight", "crop:Crop", "more:+1"]);
|
|
80
|
+
expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%");
|
|
81
|
+
});
|
|
82
|
+
});
|