@hyperframes/studio 0.8.106 → 0.8.107
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-h4mktSdm.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
- package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +367 -11
- package/dist/index.html +1 -1
- package/dist/index.js +1064 -714
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -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/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/domEditOverlayStartGesture.ts +5 -2
- 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 +4 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -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 +41 -23
- 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/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 +122 -3
- 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/usePreviewPersistence.ts +6 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +7 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/timelineLayout.ts +0 -28
- 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 +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -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/sourcePatcher.ts +1 -1
- 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 +63 -0
- package/src/utils/studioPendingEdits.ts +16 -7
- 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-ICTZJPQQ.js.map +0 -1
|
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
|
|
7
7
|
import { MediaSection } from "./propertyPanelMediaSection";
|
|
8
8
|
import type { DomEditSelection } from "./domEditing";
|
|
9
|
+
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
9
10
|
import { formatTimingValue } from "./propertyPanelHelpers";
|
|
10
11
|
import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
|
|
11
12
|
|
|
@@ -62,6 +63,7 @@ function renderSection(overrides: Partial<DomEditSelection> = {}) {
|
|
|
62
63
|
onSetStyle={vi.fn()}
|
|
63
64
|
onSetAttribute={vi.fn()}
|
|
64
65
|
onSetHtmlAttribute={vi.fn()}
|
|
66
|
+
onSetAttributeBatch={vi.fn()}
|
|
65
67
|
/>,
|
|
66
68
|
);
|
|
67
69
|
});
|
|
@@ -102,6 +104,7 @@ describe("FlatMediaSection — cutout", () => {
|
|
|
102
104
|
onSetStyle={vi.fn()}
|
|
103
105
|
onSetAttribute={onSetAttribute}
|
|
104
106
|
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
107
|
+
onSetAttributeBatch={vi.fn()}
|
|
105
108
|
onRemoveBackground={onRemoveBackground}
|
|
106
109
|
/>,
|
|
107
110
|
);
|
|
@@ -149,6 +152,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
|
|
149
152
|
onSetStyle={vi.fn()}
|
|
150
153
|
onSetAttribute={onSetAttribute}
|
|
151
154
|
onSetHtmlAttribute={vi.fn()}
|
|
155
|
+
onSetAttributeBatch={vi.fn()}
|
|
152
156
|
/>,
|
|
153
157
|
);
|
|
154
158
|
});
|
|
@@ -174,6 +178,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
|
|
174
178
|
onSetStyle={vi.fn()}
|
|
175
179
|
onSetAttribute={onSetAttribute}
|
|
176
180
|
onSetHtmlAttribute={vi.fn()}
|
|
181
|
+
onSetAttributeBatch={vi.fn()}
|
|
177
182
|
/>,
|
|
178
183
|
);
|
|
179
184
|
});
|
|
@@ -208,6 +213,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
|
|
208
213
|
onSetStyle={vi.fn()}
|
|
209
214
|
onSetAttribute={onSetAttribute}
|
|
210
215
|
onSetHtmlAttribute={vi.fn()}
|
|
216
|
+
onSetAttributeBatch={vi.fn()}
|
|
211
217
|
volumeAutomated
|
|
212
218
|
onCommitVolumeAt={onCommitVolumeAt}
|
|
213
219
|
/>,
|
|
@@ -242,6 +248,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
|
|
242
248
|
onSetStyle={vi.fn()}
|
|
243
249
|
onSetAttribute={onSetAttribute}
|
|
244
250
|
onSetHtmlAttribute={vi.fn()}
|
|
251
|
+
onSetAttributeBatch={vi.fn()}
|
|
245
252
|
/>,
|
|
246
253
|
);
|
|
247
254
|
});
|
|
@@ -273,6 +280,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
|
|
273
280
|
onSetStyle={vi.fn()}
|
|
274
281
|
onSetAttribute={onSetAttribute}
|
|
275
282
|
onSetHtmlAttribute={vi.fn()}
|
|
283
|
+
onSetAttributeBatch={vi.fn()}
|
|
276
284
|
/>,
|
|
277
285
|
);
|
|
278
286
|
});
|
|
@@ -308,6 +316,7 @@ describe("FlatMediaSection — loop/muted/has-audio", () => {
|
|
|
308
316
|
onSetStyle={vi.fn()}
|
|
309
317
|
onSetAttribute={onSetAttribute}
|
|
310
318
|
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
319
|
+
onSetAttributeBatch={vi.fn()}
|
|
311
320
|
/>,
|
|
312
321
|
);
|
|
313
322
|
});
|
|
@@ -323,92 +332,6 @@ describe("FlatMediaSection — loop/muted/has-audio", () => {
|
|
|
323
332
|
expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true");
|
|
324
333
|
act(() => root.unmount());
|
|
325
334
|
});
|
|
326
|
-
|
|
327
|
-
it("toggles muted via onSetHtmlAttribute", () => {
|
|
328
|
-
const onSetHtmlAttribute = vi.fn();
|
|
329
|
-
const onSetAttribute = vi.fn();
|
|
330
|
-
const element = makeVideoElement();
|
|
331
|
-
const host = document.createElement("div");
|
|
332
|
-
document.body.append(host);
|
|
333
|
-
const root = createRoot(host);
|
|
334
|
-
act(() => {
|
|
335
|
-
root.render(
|
|
336
|
-
<FlatMediaSection
|
|
337
|
-
projectDir={null}
|
|
338
|
-
element={element}
|
|
339
|
-
styles={{}}
|
|
340
|
-
onSetStyle={vi.fn()}
|
|
341
|
-
onSetAttribute={onSetAttribute}
|
|
342
|
-
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
343
|
-
/>,
|
|
344
|
-
);
|
|
345
|
-
});
|
|
346
|
-
const mutedToggle = host.querySelector<HTMLButtonElement>(
|
|
347
|
-
'[data-flat-toggle="true"][aria-label="Muted"]',
|
|
348
|
-
);
|
|
349
|
-
expect(mutedToggle?.getAttribute("aria-checked")).toBe("false");
|
|
350
|
-
act(() => mutedToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
351
|
-
expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true");
|
|
352
|
-
act(() => root.unmount());
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it("enables has-audio-track and clears muted on click", () => {
|
|
356
|
-
const onSetHtmlAttribute = vi.fn();
|
|
357
|
-
const onSetAttribute = vi.fn();
|
|
358
|
-
const element = makeVideoElement();
|
|
359
|
-
const host = document.createElement("div");
|
|
360
|
-
document.body.append(host);
|
|
361
|
-
const root = createRoot(host);
|
|
362
|
-
act(() => {
|
|
363
|
-
root.render(
|
|
364
|
-
<FlatMediaSection
|
|
365
|
-
projectDir={null}
|
|
366
|
-
element={element}
|
|
367
|
-
styles={{}}
|
|
368
|
-
onSetStyle={vi.fn()}
|
|
369
|
-
onSetAttribute={onSetAttribute}
|
|
370
|
-
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
371
|
-
/>,
|
|
372
|
-
);
|
|
373
|
-
});
|
|
374
|
-
const hasAudioToggle = host.querySelector<HTMLButtonElement>(
|
|
375
|
-
'[data-flat-toggle="true"][aria-label="Has audio track"]',
|
|
376
|
-
);
|
|
377
|
-
expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("false");
|
|
378
|
-
act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
379
|
-
expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "true");
|
|
380
|
-
expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", null);
|
|
381
|
-
act(() => root.unmount());
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it("disables has-audio-track and sets muted on click", () => {
|
|
385
|
-
const onSetHtmlAttribute = vi.fn();
|
|
386
|
-
const onSetAttribute = vi.fn();
|
|
387
|
-
const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
|
|
388
|
-
const host = document.createElement("div");
|
|
389
|
-
document.body.append(host);
|
|
390
|
-
const root = createRoot(host);
|
|
391
|
-
act(() => {
|
|
392
|
-
root.render(
|
|
393
|
-
<FlatMediaSection
|
|
394
|
-
projectDir={null}
|
|
395
|
-
element={element}
|
|
396
|
-
styles={{}}
|
|
397
|
-
onSetStyle={vi.fn()}
|
|
398
|
-
onSetAttribute={onSetAttribute}
|
|
399
|
-
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
400
|
-
/>,
|
|
401
|
-
);
|
|
402
|
-
});
|
|
403
|
-
const hasAudioToggle = host.querySelector<HTMLButtonElement>(
|
|
404
|
-
'[data-flat-toggle="true"][aria-label="Has audio track"]',
|
|
405
|
-
);
|
|
406
|
-
expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true");
|
|
407
|
-
act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
408
|
-
expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "");
|
|
409
|
-
expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true");
|
|
410
|
-
act(() => root.unmount());
|
|
411
|
-
});
|
|
412
335
|
});
|
|
413
336
|
|
|
414
337
|
describe("FlatMediaSection — fit/position", () => {
|
|
@@ -428,6 +351,7 @@ describe("FlatMediaSection — fit/position", () => {
|
|
|
428
351
|
onSetStyle={onSetStyle}
|
|
429
352
|
onSetAttribute={vi.fn()}
|
|
430
353
|
onSetHtmlAttribute={vi.fn()}
|
|
354
|
+
onSetAttributeBatch={vi.fn()}
|
|
431
355
|
/>,
|
|
432
356
|
);
|
|
433
357
|
});
|
|
@@ -461,6 +385,7 @@ describe("FlatMediaSection — fit/position", () => {
|
|
|
461
385
|
onSetStyle={onSetStyle}
|
|
462
386
|
onSetAttribute={vi.fn()}
|
|
463
387
|
onSetHtmlAttribute={vi.fn()}
|
|
388
|
+
onSetAttributeBatch={vi.fn()}
|
|
464
389
|
/>,
|
|
465
390
|
);
|
|
466
391
|
});
|
|
@@ -504,6 +429,7 @@ function renderWithRate(element: DomEditSelection, onSetAttribute = vi.fn()) {
|
|
|
504
429
|
onSetStyle={vi.fn()}
|
|
505
430
|
onSetAttribute={onSetAttribute}
|
|
506
431
|
onSetHtmlAttribute={vi.fn()}
|
|
432
|
+
onSetAttributeBatch={vi.fn()}
|
|
507
433
|
rate={{
|
|
508
434
|
automated: false,
|
|
509
435
|
automatedValue: undefined,
|
|
@@ -543,7 +469,9 @@ describe("FlatMediaSection — audio clips", () => {
|
|
|
543
469
|
);
|
|
544
470
|
act(() => audio.root.unmount());
|
|
545
471
|
|
|
546
|
-
const
|
|
472
|
+
const mutedSelection = makeVideoElement({ dataAttributes: { duration: "10" } });
|
|
473
|
+
mutedSelection.element.setAttribute("muted", "");
|
|
474
|
+
const silentVideo = renderWithRate(mutedSelection);
|
|
547
475
|
expect(labelsOf(silentVideo.host)).not.toContain("Fade in");
|
|
548
476
|
act(() => silentVideo.root.unmount());
|
|
549
477
|
|
|
@@ -659,6 +587,7 @@ describe("FlatMediaSection — audio clips", () => {
|
|
|
659
587
|
onSetStyle={vi.fn()}
|
|
660
588
|
onSetAttribute={onSetAttribute}
|
|
661
589
|
onSetHtmlAttribute={vi.fn()}
|
|
590
|
+
onSetAttributeBatch={vi.fn()}
|
|
662
591
|
/>,
|
|
663
592
|
);
|
|
664
593
|
});
|
|
@@ -708,3 +637,273 @@ function typeInto(input: HTMLInputElement, value: string) {
|
|
|
708
637
|
setter?.call(input, value);
|
|
709
638
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
710
639
|
}
|
|
640
|
+
|
|
641
|
+
type PanelKind = "flat" | "design";
|
|
642
|
+
|
|
643
|
+
type BatchOptions = { label: string; prepareContent?: (html: string) => string };
|
|
644
|
+
|
|
645
|
+
function makeHandlers() {
|
|
646
|
+
return {
|
|
647
|
+
onSetAttribute: vi.fn(),
|
|
648
|
+
onSetHtmlAttribute: vi.fn(),
|
|
649
|
+
onSetAttributeBatch: vi.fn<
|
|
650
|
+
(
|
|
651
|
+
selection: DomEditSelection,
|
|
652
|
+
ops: PatchOperation[],
|
|
653
|
+
options: BatchOptions,
|
|
654
|
+
) => Promise<boolean>
|
|
655
|
+
>(async () => true),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function mountPanel(
|
|
660
|
+
kind: PanelKind,
|
|
661
|
+
element: DomEditSelection,
|
|
662
|
+
handlers: ReturnType<typeof makeHandlers> & {
|
|
663
|
+
onRemoveBackground?: () => Promise<{ outputPath: string }>;
|
|
664
|
+
},
|
|
665
|
+
) {
|
|
666
|
+
const host = document.createElement("div");
|
|
667
|
+
document.body.append(host);
|
|
668
|
+
const root = createRoot(host);
|
|
669
|
+
const Panel = kind === "flat" ? FlatMediaSection : MediaSection;
|
|
670
|
+
act(() => {
|
|
671
|
+
root.render(
|
|
672
|
+
<Panel
|
|
673
|
+
projectId="p1"
|
|
674
|
+
projectDir={null}
|
|
675
|
+
element={element}
|
|
676
|
+
styles={{}}
|
|
677
|
+
onSetStyle={vi.fn()}
|
|
678
|
+
{...handlers}
|
|
679
|
+
/>,
|
|
680
|
+
);
|
|
681
|
+
});
|
|
682
|
+
return { host, root };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function flush() {
|
|
686
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function clickToggle(kind: PanelKind, host: HTMLElement, label: string, option: string) {
|
|
690
|
+
if (kind === "flat") {
|
|
691
|
+
const toggle = host.querySelector<HTMLButtonElement>(
|
|
692
|
+
`[data-flat-toggle="true"][aria-label="${label}"]`,
|
|
693
|
+
);
|
|
694
|
+
if (!toggle) throw new Error(`expected ${label} toggle`);
|
|
695
|
+
await act(async () => {
|
|
696
|
+
toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
697
|
+
await flush();
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const row = Array.from(host.querySelectorAll("span")).find(
|
|
702
|
+
(n) => n.textContent === label,
|
|
703
|
+
)?.parentElement;
|
|
704
|
+
const button = Array.from(row?.querySelectorAll("button") ?? []).find(
|
|
705
|
+
(b) => b.textContent === option,
|
|
706
|
+
);
|
|
707
|
+
if (!button) throw new Error(`expected ${label} segmented button`);
|
|
708
|
+
await act(async () => {
|
|
709
|
+
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
710
|
+
await flush();
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function stubHasAudioProbe(hasAudio: boolean | "fail") {
|
|
715
|
+
const fetchMock = vi.fn(async (_input: Parameters<typeof fetch>[0]) =>
|
|
716
|
+
hasAudio === "fail"
|
|
717
|
+
? new Response("nope", { status: 500 })
|
|
718
|
+
: new Response(JSON.stringify({ metadata: { hasAudio } })),
|
|
719
|
+
);
|
|
720
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
721
|
+
return fetchMock;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const htmlOp = (property: string, value: string | null) => ({
|
|
725
|
+
type: "html-attribute",
|
|
726
|
+
property,
|
|
727
|
+
value,
|
|
728
|
+
});
|
|
729
|
+
const dataOp = (property: string, value: string | null) => ({
|
|
730
|
+
type: "attribute",
|
|
731
|
+
property,
|
|
732
|
+
value,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe.each<PanelKind>(["flat", "design"])("%s panel — Muted is one atomic edit", (kind) => {
|
|
736
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
737
|
+
|
|
738
|
+
it("muting an audible video sets muted and removes has-audio in ONE write", async () => {
|
|
739
|
+
const handlers = makeHandlers();
|
|
740
|
+
const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
|
|
741
|
+
const { host, root } = mountPanel(kind, element, handlers);
|
|
742
|
+
await clickToggle(kind, host, "Muted", "On");
|
|
743
|
+
expect(handlers.onSetAttributeBatch).toHaveBeenCalledTimes(1);
|
|
744
|
+
expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
|
|
745
|
+
htmlOp("muted", "true"),
|
|
746
|
+
dataOp("has-audio", null),
|
|
747
|
+
]);
|
|
748
|
+
expect(handlers.onSetAttribute).not.toHaveBeenCalled();
|
|
749
|
+
expect(handlers.onSetHtmlAttribute).not.toHaveBeenCalled();
|
|
750
|
+
act(() => root.unmount());
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("unmuting a video the probe hears stamps has-audio=true in the same write", async () => {
|
|
754
|
+
const probe = stubHasAudioProbe(true);
|
|
755
|
+
const handlers = makeHandlers();
|
|
756
|
+
const element = makeVideoElement();
|
|
757
|
+
element.element.setAttribute("muted", "");
|
|
758
|
+
const { host, root } = mountPanel(kind, element, handlers);
|
|
759
|
+
await clickToggle(kind, host, "Muted", "Off");
|
|
760
|
+
expect(String(probe.mock.calls[0]?.[0])).toContain("assets%2Fintro-loop.mp4");
|
|
761
|
+
expect(handlers.onSetAttributeBatch).toHaveBeenCalledTimes(1);
|
|
762
|
+
expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
|
|
763
|
+
htmlOp("muted", null),
|
|
764
|
+
dataOp("has-audio", "true"),
|
|
765
|
+
]);
|
|
766
|
+
act(() => root.unmount());
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("unmuting a silent-probe video writes has-audio=false", async () => {
|
|
770
|
+
stubHasAudioProbe(false);
|
|
771
|
+
const handlers = makeHandlers();
|
|
772
|
+
const element = makeVideoElement();
|
|
773
|
+
element.element.setAttribute("muted", "");
|
|
774
|
+
const { host, root } = mountPanel(kind, element, handlers);
|
|
775
|
+
await clickToggle(kind, host, "Muted", "Off");
|
|
776
|
+
expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
|
|
777
|
+
htmlOp("muted", null),
|
|
778
|
+
dataOp("has-audio", "false"),
|
|
779
|
+
]);
|
|
780
|
+
act(() => root.unmount());
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("unmuting when the probe fails leaves has-audio absent", async () => {
|
|
784
|
+
stubHasAudioProbe("fail");
|
|
785
|
+
const handlers = makeHandlers();
|
|
786
|
+
const element = makeVideoElement();
|
|
787
|
+
element.element.setAttribute("muted", "");
|
|
788
|
+
const { host, root } = mountPanel(kind, element, handlers);
|
|
789
|
+
await clickToggle(kind, host, "Muted", "Off");
|
|
790
|
+
expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
|
|
791
|
+
htmlOp("muted", null),
|
|
792
|
+
dataOp("has-audio", null),
|
|
793
|
+
]);
|
|
794
|
+
act(() => root.unmount());
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("audio Muted toggles only the muted attribute", async () => {
|
|
798
|
+
const handlers = makeHandlers();
|
|
799
|
+
const { host, root } = mountPanel(kind, makeAudioElement({}), handlers);
|
|
800
|
+
await clickToggle(kind, host, "Muted", "On");
|
|
801
|
+
expect(handlers.onSetAttributeBatch).toHaveBeenCalledTimes(1);
|
|
802
|
+
expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([htmlOp("muted", "true")]);
|
|
803
|
+
act(() => root.unmount());
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it("Has audio track No removes has-audio and mutes in ONE write", async () => {
|
|
807
|
+
const handlers = makeHandlers();
|
|
808
|
+
const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
|
|
809
|
+
const { host, root } = mountPanel(kind, element, handlers);
|
|
810
|
+
await clickToggle(kind, host, "Has audio track", "No");
|
|
811
|
+
expect(handlers.onSetAttributeBatch).toHaveBeenCalledTimes(1);
|
|
812
|
+
expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
|
|
813
|
+
dataOp("has-audio", null),
|
|
814
|
+
htmlOp("muted", "true"),
|
|
815
|
+
]);
|
|
816
|
+
act(() => root.unmount());
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("Has audio track Yes stamps has-audio and clears muted in ONE write", async () => {
|
|
820
|
+
const handlers = makeHandlers();
|
|
821
|
+
const element = makeVideoElement();
|
|
822
|
+
element.element.setAttribute("muted", "");
|
|
823
|
+
const { host, root } = mountPanel(kind, element, handlers);
|
|
824
|
+
await clickToggle(kind, host, "Has audio track", "Yes");
|
|
825
|
+
expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
|
|
826
|
+
dataOp("has-audio", "true"),
|
|
827
|
+
htmlOp("muted", null),
|
|
828
|
+
]);
|
|
829
|
+
act(() => root.unmount());
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the sound", (kind) => {
|
|
834
|
+
async function runCutout(element: DomEditSelection) {
|
|
835
|
+
const handlers = makeHandlers();
|
|
836
|
+
const onRemoveBackground = vi.fn().mockResolvedValue({ outputPath: "assets/cut.webm" });
|
|
837
|
+
const confirm = vi.spyOn(window, "confirm");
|
|
838
|
+
const { host, root } = mountPanel(kind, element, { ...handlers, onRemoveBackground });
|
|
839
|
+
const button =
|
|
840
|
+
kind === "flat"
|
|
841
|
+
? host.querySelector<HTMLButtonElement>('[data-flat-media-remove-bg="true"]')
|
|
842
|
+
: Array.from(host.querySelectorAll("button")).find((b) =>
|
|
843
|
+
/remove bg/i.test(b.textContent ?? ""),
|
|
844
|
+
);
|
|
845
|
+
if (!button) throw new Error("expected remove background button");
|
|
846
|
+
await act(async () => {
|
|
847
|
+
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
848
|
+
await flush();
|
|
849
|
+
});
|
|
850
|
+
const text = host.textContent ?? "";
|
|
851
|
+
act(() => root.unmount());
|
|
852
|
+
return { ...handlers, confirm, text };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
afterEach(() => vi.restoreAllMocks());
|
|
856
|
+
|
|
857
|
+
it("audible video: one write mutes onto the cutout and inserts a linked sibling audio", async () => {
|
|
858
|
+
const element = makeVideoElement({
|
|
859
|
+
dataAttributes: { "has-audio": "true", volume: "0.5" },
|
|
860
|
+
});
|
|
861
|
+
element.element.setAttribute("id", "s1-bg");
|
|
862
|
+
element.element.setAttribute("data-has-audio", "true");
|
|
863
|
+
element.element.setAttribute("data-volume", "0.5");
|
|
864
|
+
const { onSetAttributeBatch, onSetAttribute, onSetHtmlAttribute, confirm, text } =
|
|
865
|
+
await runCutout(element);
|
|
866
|
+
|
|
867
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
868
|
+
expect(onSetAttribute).not.toHaveBeenCalled();
|
|
869
|
+
expect(onSetHtmlAttribute).not.toHaveBeenCalled();
|
|
870
|
+
expect(onSetAttributeBatch).toHaveBeenCalledTimes(1);
|
|
871
|
+
const [selection, ops, options] = onSetAttributeBatch.mock.calls[0] ?? [];
|
|
872
|
+
expect(selection).toBe(element);
|
|
873
|
+
expect(ops).toEqual([
|
|
874
|
+
htmlOp("src", "assets/cut.webm"),
|
|
875
|
+
htmlOp("muted", "true"),
|
|
876
|
+
dataOp("has-audio", null),
|
|
877
|
+
dataOp("volume", null),
|
|
878
|
+
dataOp("link", "link"),
|
|
879
|
+
]);
|
|
880
|
+
const source =
|
|
881
|
+
'<div data-composition-id="main"><video id="s1-bg" src="assets/cut.webm" muted data-start="2" data-duration="3" data-track-index="0" data-link="link"></video></div>';
|
|
882
|
+
const prepared = options?.prepareContent?.(source) ?? "";
|
|
883
|
+
const audio = new DOMParser()
|
|
884
|
+
.parseFromString(prepared, "text/html")
|
|
885
|
+
.getElementById("s1-bg-audio");
|
|
886
|
+
expect(audio?.getAttribute("src")).toBe("assets/intro-loop.mp4");
|
|
887
|
+
expect(audio?.getAttribute("data-start")).toBe("2");
|
|
888
|
+
expect(audio?.getAttribute("data-duration")).toBe("3");
|
|
889
|
+
expect(audio?.getAttribute("data-link")).toBe("link");
|
|
890
|
+
expect(audio?.getAttribute("data-volume")).toBe("0.5");
|
|
891
|
+
expect(text).toContain("Background removed. Sound kept on a linked audio track.");
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it("silent video: one write swaps the src and inserts no audio", async () => {
|
|
895
|
+
const silent = makeVideoElement();
|
|
896
|
+
silent.element.setAttribute("muted", "");
|
|
897
|
+
const { onSetAttributeBatch, confirm, text } = await runCutout(silent);
|
|
898
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
899
|
+
expect(onSetAttributeBatch).toHaveBeenCalledTimes(1);
|
|
900
|
+
const [, ops, options] = onSetAttributeBatch.mock.calls[0] ?? [];
|
|
901
|
+
expect(ops).toEqual([
|
|
902
|
+
htmlOp("src", "assets/cut.webm"),
|
|
903
|
+
htmlOp("muted", "true"),
|
|
904
|
+
dataOp("has-audio", null),
|
|
905
|
+
]);
|
|
906
|
+
expect(options?.prepareContent).toBeUndefined();
|
|
907
|
+
expect(text).toContain("Applied cutout");
|
|
908
|
+
});
|
|
909
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
+
import { isVideoAudible } from "../../player/lib/timelineElementHelpers";
|
|
2
3
|
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
3
4
|
import { Check, ClipboardList } from "../../icons/SystemIcons";
|
|
4
5
|
import type { DomEditSelection } from "./domEditing";
|
|
@@ -34,15 +35,19 @@ import {
|
|
|
34
35
|
readFadeSeconds,
|
|
35
36
|
} from "@hyperframes/core/audio-fade";
|
|
36
37
|
import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspectorInput";
|
|
38
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
39
|
+
import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
|
|
37
40
|
|
|
38
41
|
// fallow-ignore-next-line complexity
|
|
39
42
|
export function FlatMediaSection({
|
|
43
|
+
projectId = null,
|
|
40
44
|
projectDir,
|
|
41
45
|
element,
|
|
42
46
|
styles,
|
|
43
47
|
onSetStyle,
|
|
44
48
|
onSetAttribute,
|
|
45
49
|
onSetHtmlAttribute,
|
|
50
|
+
onSetAttributeBatch,
|
|
46
51
|
onRemoveBackground,
|
|
47
52
|
volumeAutomated,
|
|
48
53
|
onAutomateVolume,
|
|
@@ -51,12 +56,14 @@ export function FlatMediaSection({
|
|
|
51
56
|
automatedVolumeValue,
|
|
52
57
|
rate,
|
|
53
58
|
}: {
|
|
59
|
+
projectId?: string | null;
|
|
54
60
|
projectDir: string | null;
|
|
55
61
|
element: DomEditSelection;
|
|
56
62
|
styles: Record<string, string>;
|
|
57
63
|
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
58
64
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
59
65
|
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
66
|
+
onSetAttributeBatch: CommitDomAttributeBatch;
|
|
60
67
|
/** A volume lane in the timeline drives the level; the slider writes a keyframe instead. */
|
|
61
68
|
volumeAutomated?: boolean;
|
|
62
69
|
onAutomateVolume?: () => void;
|
|
@@ -104,7 +111,11 @@ export function FlatMediaSection({
|
|
|
104
111
|
const fadeMax = clipDuration > 0 ? clipDuration : 10;
|
|
105
112
|
const hasLoop = el.hasAttribute("loop");
|
|
106
113
|
const hasMuted = el.hasAttribute("muted");
|
|
107
|
-
const hasAudio =
|
|
114
|
+
const hasAudio = isVideoAudible({
|
|
115
|
+
tag: el.tagName,
|
|
116
|
+
hasAudioAttr: element.dataAttributes["has-audio"],
|
|
117
|
+
muted: el.hasAttribute("muted"),
|
|
118
|
+
});
|
|
108
119
|
const objectFit = styles["object-fit"] || "contain";
|
|
109
120
|
const objectPosition = styles["object-position"] || "center";
|
|
110
121
|
|
|
@@ -128,13 +139,7 @@ export function FlatMediaSection({
|
|
|
128
139
|
setCreatePlate(false);
|
|
129
140
|
}, [srcAttr]);
|
|
130
141
|
|
|
131
|
-
const
|
|
132
|
-
await onSetHtmlAttribute("src", result.outputPath);
|
|
133
|
-
if (isVideo) {
|
|
134
|
-
await onSetAttribute("has-audio", "");
|
|
135
|
-
await onSetHtmlAttribute("muted", "true");
|
|
136
|
-
}
|
|
137
|
-
};
|
|
142
|
+
const mediaEdit = { element, projectId, projectSrc, commit: onSetAttributeBatch };
|
|
138
143
|
|
|
139
144
|
const runBackgroundRemoval = async () => {
|
|
140
145
|
if (!onRemoveBackground || !projectSrc || removeBusy) return;
|
|
@@ -147,8 +152,8 @@ export function FlatMediaSection({
|
|
|
147
152
|
quality,
|
|
148
153
|
onProgress: setRemoveProgress,
|
|
149
154
|
});
|
|
150
|
-
await
|
|
151
|
-
setRemoveProgress({ status: "complete", progress: 100, stage
|
|
155
|
+
const stage = await commitCutout(mediaEdit, result.outputPath, hasAudio);
|
|
156
|
+
setRemoveProgress({ status: "complete", progress: 100, stage, ...result });
|
|
152
157
|
} catch (error) {
|
|
153
158
|
setRemoveProgress({
|
|
154
159
|
status: "failed",
|
|
@@ -362,21 +367,13 @@ export function FlatMediaSection({
|
|
|
362
367
|
<FlatToggle
|
|
363
368
|
label="Muted"
|
|
364
369
|
checked={hasMuted}
|
|
365
|
-
onChange={(next) => void
|
|
370
|
+
onChange={(next) => void commitMutedToggle(mediaEdit, next)}
|
|
366
371
|
/>
|
|
367
372
|
{isVideo && (
|
|
368
373
|
<FlatToggle
|
|
369
374
|
label="Has audio track"
|
|
370
375
|
checked={hasAudio}
|
|
371
|
-
onChange={(next) =>
|
|
372
|
-
if (next) {
|
|
373
|
-
void onSetAttribute("has-audio", "true");
|
|
374
|
-
void onSetHtmlAttribute("muted", null);
|
|
375
|
-
} else {
|
|
376
|
-
void onSetAttribute("has-audio", "");
|
|
377
|
-
void onSetHtmlAttribute("muted", "true");
|
|
378
|
-
}
|
|
379
|
-
}}
|
|
376
|
+
onChange={(next) => void commitHasAudioToggle(mediaEdit, next)}
|
|
380
377
|
/>
|
|
381
378
|
)}
|
|
382
379
|
</>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
+
import { isVideoAudible } from "../../player/lib/timelineElementHelpers";
|
|
2
3
|
import { Check, ClipboardList, Film, Music, Scissors } from "../../icons/SystemIcons";
|
|
3
4
|
import type { DomEditSelection } from "./domEditing";
|
|
4
5
|
import {
|
|
@@ -22,23 +23,29 @@ import {
|
|
|
22
23
|
audioGainToFaderPosition,
|
|
23
24
|
audioGainToText,
|
|
24
25
|
} from "@hyperframes/core/audio-gain";
|
|
26
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
27
|
+
import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
|
|
25
28
|
|
|
26
29
|
// fallow-ignore-next-line complexity
|
|
27
30
|
export function MediaSection({
|
|
31
|
+
projectId = null,
|
|
28
32
|
projectDir,
|
|
29
33
|
element,
|
|
30
34
|
styles,
|
|
31
35
|
onSetStyle,
|
|
32
36
|
onSetAttribute,
|
|
33
37
|
onSetHtmlAttribute,
|
|
38
|
+
onSetAttributeBatch,
|
|
34
39
|
onRemoveBackground,
|
|
35
40
|
}: {
|
|
41
|
+
projectId?: string | null;
|
|
36
42
|
projectDir: string | null;
|
|
37
43
|
element: DomEditSelection;
|
|
38
44
|
styles: Record<string, string>;
|
|
39
45
|
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
40
46
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
41
47
|
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
48
|
+
onSetAttributeBatch: CommitDomAttributeBatch;
|
|
42
49
|
onRemoveBackground?: (
|
|
43
50
|
inputPath: string,
|
|
44
51
|
options: {
|
|
@@ -62,7 +69,11 @@ export function MediaSection({
|
|
|
62
69
|
|
|
63
70
|
const hasLoop = el.hasAttribute("loop");
|
|
64
71
|
const hasMuted = el.hasAttribute("muted");
|
|
65
|
-
const hasAudio =
|
|
72
|
+
const hasAudio = isVideoAudible({
|
|
73
|
+
tag: el.tagName,
|
|
74
|
+
hasAudioAttr: element.dataAttributes["has-audio"],
|
|
75
|
+
muted: el.hasAttribute("muted"),
|
|
76
|
+
});
|
|
66
77
|
|
|
67
78
|
const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
|
|
68
79
|
|
|
@@ -96,13 +107,7 @@ export function MediaSection({
|
|
|
96
107
|
setCreatePlate(false);
|
|
97
108
|
}, [srcAttr]);
|
|
98
109
|
|
|
99
|
-
const
|
|
100
|
-
await onSetHtmlAttribute("src", result.outputPath);
|
|
101
|
-
if (isVideo) {
|
|
102
|
-
await onSetAttribute("has-audio", "");
|
|
103
|
-
await onSetHtmlAttribute("muted", "true");
|
|
104
|
-
}
|
|
105
|
-
};
|
|
110
|
+
const mediaEdit = { element, projectId, projectSrc, commit: onSetAttributeBatch };
|
|
106
111
|
|
|
107
112
|
const runBackgroundRemoval = async () => {
|
|
108
113
|
if (!onRemoveBackground || !projectSrc || removeBusy) return;
|
|
@@ -115,13 +120,8 @@ export function MediaSection({
|
|
|
115
120
|
quality,
|
|
116
121
|
onProgress: setRemoveProgress,
|
|
117
122
|
});
|
|
118
|
-
await
|
|
119
|
-
setRemoveProgress({
|
|
120
|
-
status: "complete",
|
|
121
|
-
progress: 100,
|
|
122
|
-
stage: "Applied cutout",
|
|
123
|
-
...result,
|
|
124
|
-
});
|
|
123
|
+
const stage = await commitCutout(mediaEdit, result.outputPath, hasAudio);
|
|
124
|
+
setRemoveProgress({ status: "complete", progress: 100, stage, ...result });
|
|
125
125
|
} catch (error) {
|
|
126
126
|
setRemoveProgress({
|
|
127
127
|
status: "failed",
|
|
@@ -320,9 +320,7 @@ export function MediaSection({
|
|
|
320
320
|
<SegmentedControl
|
|
321
321
|
trackName="Muted"
|
|
322
322
|
value={hasMuted ? "on" : "off"}
|
|
323
|
-
onChange={(next) =>
|
|
324
|
-
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
|
|
325
|
-
}}
|
|
323
|
+
onChange={(next) => void commitMutedToggle(mediaEdit, next === "on")}
|
|
326
324
|
options={[
|
|
327
325
|
{ label: "On", value: "on" },
|
|
328
326
|
{ label: "Off", value: "off" },
|
|
@@ -337,15 +335,7 @@ export function MediaSection({
|
|
|
337
335
|
<SegmentedControl
|
|
338
336
|
trackName="Has audio track"
|
|
339
337
|
value={hasAudio ? "yes" : "no"}
|
|
340
|
-
onChange={(next) =>
|
|
341
|
-
if (next === "yes") {
|
|
342
|
-
void onSetAttribute("has-audio", "true");
|
|
343
|
-
void onSetHtmlAttribute("muted", null);
|
|
344
|
-
} else {
|
|
345
|
-
void onSetAttribute("has-audio", "");
|
|
346
|
-
void onSetHtmlAttribute("muted", "true");
|
|
347
|
-
}
|
|
348
|
-
}}
|
|
338
|
+
onChange={(next) => void commitHasAudioToggle(mediaEdit, next === "yes")}
|
|
349
339
|
options={[
|
|
350
340
|
{ label: "Yes", value: "yes" },
|
|
351
341
|
{ label: "No", value: "no" },
|