@bycrux/editor 0.12.0 → 1.0.1
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/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { useState, type ReactNode } from 'react'
|
|
3
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
|
4
|
+
import type { AudioTrack, VisualItem } from '../../../schema'
|
|
5
|
+
import ClipPropertiesPanel, { type ClipSelection } from '../ClipPropertiesPanel'
|
|
6
|
+
|
|
7
|
+
// The panel now persists its active clip tab to localStorage (see
|
|
8
|
+
// ClipPropertiesPanel's CLIP_PANEL_TAB_STORAGE_KEY) — clear it between tests
|
|
9
|
+
// so one test's tab choice can't leak into the next, same pattern as
|
|
10
|
+
// LeftPanelTabs' own test file.
|
|
11
|
+
beforeEach(() => window.localStorage.clear())
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
/** Clicks a clip-panel tab button by its accessible name (e.g. "Volume",
|
|
15
|
+
* "Speed", "Crop", "Generate", "Transform"). */
|
|
16
|
+
function openClipTab(name: string) {
|
|
17
|
+
fireEvent.click(screen.getByRole('button', { name }))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clipItem(over: Partial<VisualItem> = {}): VisualItem {
|
|
21
|
+
return {
|
|
22
|
+
id: 'c1',
|
|
23
|
+
type: 'video',
|
|
24
|
+
src: 'clip.mp4',
|
|
25
|
+
start: 0,
|
|
26
|
+
end: 10,
|
|
27
|
+
inPoint: 100,
|
|
28
|
+
outPoint: 110,
|
|
29
|
+
...over,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function audioTrack(over: Partial<AudioTrack> = {}): AudioTrack {
|
|
34
|
+
return {
|
|
35
|
+
id: 'a1',
|
|
36
|
+
src: '/media/voiceover-final.mp3',
|
|
37
|
+
start: 0,
|
|
38
|
+
end: 10,
|
|
39
|
+
...over,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeCallbacks() {
|
|
44
|
+
return {
|
|
45
|
+
onPreviewClip: vi.fn(),
|
|
46
|
+
onCommitClip: vi.fn(),
|
|
47
|
+
onChangeClip: vi.fn(),
|
|
48
|
+
onPreviewAudio: vi.fn(),
|
|
49
|
+
onCommitAudio: vi.fn(),
|
|
50
|
+
onChangeAudio: vi.fn(),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Callbacks = ReturnType<typeof makeCallbacks>
|
|
55
|
+
|
|
56
|
+
type ClipTabExtras = { transformSlot?: ReactNode; onOpenCrop?: () => void; generationSlot?: ReactNode }
|
|
57
|
+
|
|
58
|
+
function makeElement(selection: ClipSelection, cbs: Callbacks, extra: ClipTabExtras = {}) {
|
|
59
|
+
return (
|
|
60
|
+
<ClipPropertiesPanel
|
|
61
|
+
selection={selection}
|
|
62
|
+
onPreviewClip={cbs.onPreviewClip}
|
|
63
|
+
onCommitClip={cbs.onCommitClip}
|
|
64
|
+
onChangeClip={cbs.onChangeClip}
|
|
65
|
+
onPreviewAudio={cbs.onPreviewAudio}
|
|
66
|
+
onCommitAudio={cbs.onCommitAudio}
|
|
67
|
+
onChangeAudio={cbs.onChangeAudio}
|
|
68
|
+
{...extra}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderPanel(selection: ClipSelection, extra: ClipTabExtras = {}) {
|
|
74
|
+
const cbs = makeCallbacks()
|
|
75
|
+
const utils = render(makeElement(selection, cbs, extra))
|
|
76
|
+
return {
|
|
77
|
+
...utils,
|
|
78
|
+
...cbs,
|
|
79
|
+
/** Re-render the SAME mounted panel with a new selection, keeping the
|
|
80
|
+
* same callback spies. Used to simulate the host re-rendering from
|
|
81
|
+
* outside (e.g. an unrelated field committing elsewhere) while the
|
|
82
|
+
* operator is mid-keystroke on a field this panel owns. */
|
|
83
|
+
rerenderWith: (nextSelection: ClipSelection) => utils.rerender(makeElement(nextSelection, cbs, extra)),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Opens a nested sub-group (Fades / Ducking) by clicking its collapsible
|
|
88
|
+
* header. Both start collapsed. */
|
|
89
|
+
function openSection(name: string) {
|
|
90
|
+
fireEvent.click(screen.getByRole('button', { name: new RegExp(name) }))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A real host feeds a previewed item straight back into the tree (its own
|
|
95
|
+
* state update re-renders the panel with the new `item` before the operator
|
|
96
|
+
* releases the pointer) — that's what keeps a controlled `<input>` showing
|
|
97
|
+
* what was just dragged to. A bare mock spy does not, so `SpeedControl`'s
|
|
98
|
+
* `onPointerUp` (which reads the slider's OWN current DOM value) would see
|
|
99
|
+
* React's controlled-input revert instead of the dragged-to value. This tiny
|
|
100
|
+
* stateful wrapper stands in for that host feedback loop for the tests that
|
|
101
|
+
* exercise the speed gesture end-to-end.
|
|
102
|
+
*/
|
|
103
|
+
function ClipHarness({ initialItem, spies }: { initialItem: VisualItem; spies: Callbacks }) {
|
|
104
|
+
const [item, setItem] = useState(initialItem)
|
|
105
|
+
return (
|
|
106
|
+
<ClipPropertiesPanel
|
|
107
|
+
selection={{ kind: 'clip', item }}
|
|
108
|
+
onPreviewClip={next => { spies.onPreviewClip(next); setItem(next) }}
|
|
109
|
+
onCommitClip={spies.onCommitClip}
|
|
110
|
+
onChangeClip={next => { spies.onChangeClip(next); setItem(next) }}
|
|
111
|
+
onPreviewAudio={spies.onPreviewAudio}
|
|
112
|
+
onCommitAudio={spies.onCommitAudio}
|
|
113
|
+
onChangeAudio={spies.onChangeAudio}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe('ClipPropertiesPanel — empty state', () => {
|
|
119
|
+
it('shows the empty-state message when nothing is selected', () => {
|
|
120
|
+
renderPanel(null)
|
|
121
|
+
expect(screen.getByText('Select a clip to edit its properties.')).toBeTruthy()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('ClipPropertiesPanel — clip selection', () => {
|
|
126
|
+
it('renders the current volume, mute, and speed values', () => {
|
|
127
|
+
renderPanel({ kind: 'clip', item: clipItem({ volume: 1.5, muted: true, speed: 2 }) })
|
|
128
|
+
|
|
129
|
+
// No transformSlot offered by this fixture, so Speed — the first tab
|
|
130
|
+
// this video selection actually offers — is the default active tab.
|
|
131
|
+
expect(screen.getByRole('slider', { name: 'Speed' })).toHaveValue('2')
|
|
132
|
+
|
|
133
|
+
openClipTab('Volume')
|
|
134
|
+
expect(screen.getByRole('slider', { name: 'Volume' })).toHaveValue('1.5')
|
|
135
|
+
expect(screen.getByRole('switch', { name: 'Mute clip' })).toHaveAttribute('aria-checked', 'true')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('defaults volume/muted/speed the same way the modal does (1 / false / 1) when unset', () => {
|
|
139
|
+
renderPanel({ kind: 'clip', item: clipItem() })
|
|
140
|
+
|
|
141
|
+
expect(screen.getByRole('slider', { name: 'Speed' })).toHaveValue('1')
|
|
142
|
+
|
|
143
|
+
openClipTab('Volume')
|
|
144
|
+
expect(screen.getByRole('slider', { name: 'Volume' })).toHaveValue('1')
|
|
145
|
+
expect(screen.getByRole('switch', { name: 'Mute clip' })).toHaveAttribute('aria-checked', 'false')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('volume: previews on every drag step and commits once, writing item.volume', () => {
|
|
149
|
+
const { onPreviewClip, onCommitClip } = renderPanel({ kind: 'clip', item: clipItem({ volume: 1 }) })
|
|
150
|
+
openClipTab('Volume')
|
|
151
|
+
const slider = screen.getByRole('slider', { name: 'Volume' })
|
|
152
|
+
|
|
153
|
+
fireEvent.change(slider, { target: { value: '1.5' } })
|
|
154
|
+
expect(onPreviewClip).toHaveBeenCalledTimes(1)
|
|
155
|
+
expect(onPreviewClip.mock.calls[0][0]).toMatchObject({ id: 'c1', volume: 1.5 })
|
|
156
|
+
expect(onCommitClip).not.toHaveBeenCalled()
|
|
157
|
+
|
|
158
|
+
fireEvent.pointerUp(slider)
|
|
159
|
+
expect(onCommitClip).toHaveBeenCalledTimes(1)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('mute: fires exactly one discrete onChangeClip, no preview/commit', () => {
|
|
163
|
+
const { onPreviewClip, onCommitClip, onChangeClip } = renderPanel({ kind: 'clip', item: clipItem({ muted: false }) })
|
|
164
|
+
openClipTab('Volume')
|
|
165
|
+
|
|
166
|
+
fireEvent.click(screen.getByRole('switch', { name: 'Mute clip' }))
|
|
167
|
+
|
|
168
|
+
expect(onChangeClip).toHaveBeenCalledTimes(1)
|
|
169
|
+
expect(onChangeClip.mock.calls[0][0]).toMatchObject({ id: 'c1', muted: true })
|
|
170
|
+
expect(onPreviewClip).not.toHaveBeenCalled()
|
|
171
|
+
expect(onCommitClip).not.toHaveBeenCalled()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('speed: dragging previews only the number (duration untouched); commit resolves the real duration change through setClipSpeed', () => {
|
|
175
|
+
// Same fixture cuts.test.ts's own `setClipSpeed` suite uses, so the
|
|
176
|
+
// expected `end` below is directly comparable to that suite's numbers.
|
|
177
|
+
const item = clipItem({ start: 0, end: 10, inPoint: 100, outPoint: 110, speed: 1 })
|
|
178
|
+
const spies = makeCallbacks()
|
|
179
|
+
render(<ClipHarness initialItem={item} spies={spies} />)
|
|
180
|
+
const slider = screen.getByRole('slider', { name: 'Speed' })
|
|
181
|
+
|
|
182
|
+
fireEvent.change(slider, { target: { value: '2' } })
|
|
183
|
+
expect(spies.onPreviewClip).toHaveBeenCalledTimes(1)
|
|
184
|
+
// Mid-drag: the field shows 2x, but nothing has resized yet.
|
|
185
|
+
expect(spies.onPreviewClip.mock.calls[0][0]).toMatchObject({ speed: 2, end: 10 })
|
|
186
|
+
expect(spies.onCommitClip).not.toHaveBeenCalled()
|
|
187
|
+
|
|
188
|
+
fireEvent.pointerUp(slider)
|
|
189
|
+
expect(spies.onCommitClip).toHaveBeenCalledTimes(1)
|
|
190
|
+
|
|
191
|
+
// The commit path previews the setClipSpeed-resolved item (correct end)
|
|
192
|
+
// immediately before signaling commit — this IS "goes through setClipSpeed".
|
|
193
|
+
const resolved = spies.onPreviewClip.mock.calls[spies.onPreviewClip.mock.calls.length - 1][0]
|
|
194
|
+
expect(resolved).toMatchObject({ speed: 2, end: 5, inPoint: 100, outPoint: 110 })
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('speed: a preset chip previews then immediately commits the resolved duration', () => {
|
|
198
|
+
const item = clipItem({ start: 0, end: 10, inPoint: 100, outPoint: 110, speed: 1 })
|
|
199
|
+
const spies = makeCallbacks()
|
|
200
|
+
render(<ClipHarness initialItem={item} spies={spies} />)
|
|
201
|
+
|
|
202
|
+
fireEvent.click(screen.getByRole('button', { name: '4×' }))
|
|
203
|
+
|
|
204
|
+
expect(spies.onCommitClip).toHaveBeenCalledTimes(1)
|
|
205
|
+
const resolved = spies.onPreviewClip.mock.calls[spies.onPreviewClip.mock.calls.length - 1][0]
|
|
206
|
+
expect(resolved).toMatchObject({ speed: 4, end: 2.5 }) // 10 / 4
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('ClipPropertiesPanel — generation slot', () => {
|
|
211
|
+
it('renders the host-injected slot in the Generate tab when provided', () => {
|
|
212
|
+
renderPanel({ kind: 'clip', item: clipItem() }, { generationSlot: <div data-testid="gen-slot">Regenerate</div> })
|
|
213
|
+
openClipTab('Generate')
|
|
214
|
+
expect(screen.getByTestId('gen-slot')).toBeTruthy()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('offers no Generate tab, and renders nothing in its place, when absent', () => {
|
|
218
|
+
renderPanel({ kind: 'clip', item: clipItem() })
|
|
219
|
+
expect(screen.queryByRole('button', { name: 'Generate' })).toBeNull()
|
|
220
|
+
expect(screen.queryByTestId('gen-slot')).toBeNull()
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe('ClipPropertiesPanel — audio selection', () => {
|
|
225
|
+
it('renders every field with its current value, including the nested Fades and Ducking groups once opened', () => {
|
|
226
|
+
renderPanel({
|
|
227
|
+
kind: 'audio',
|
|
228
|
+
track: audioTrack({
|
|
229
|
+
label: 'VO',
|
|
230
|
+
volume: 1.4,
|
|
231
|
+
muted: true,
|
|
232
|
+
fadeIn: 0.5,
|
|
233
|
+
fadeOut: 0.8,
|
|
234
|
+
ducking: { enabled: true, depth: -9, attack: 0.2, release: 0.6 },
|
|
235
|
+
inPoint: 2,
|
|
236
|
+
outPoint: 8,
|
|
237
|
+
}),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
expect(screen.getByLabelText('Track label')).toHaveValue('VO')
|
|
241
|
+
expect(screen.getByRole('slider', { name: 'Volume' })).toHaveValue('1.4')
|
|
242
|
+
expect(screen.getByRole('switch', { name: 'Mute track' })).toHaveAttribute('aria-checked', 'true')
|
|
243
|
+
expect(screen.getByLabelText('In point')).toHaveValue(2)
|
|
244
|
+
expect(screen.getByLabelText('Out point')).toHaveValue(8)
|
|
245
|
+
|
|
246
|
+
openSection('Fades')
|
|
247
|
+
expect(screen.getByLabelText('Fade in')).toHaveValue(0.5)
|
|
248
|
+
expect(screen.getByLabelText('Fade out')).toHaveValue(0.8)
|
|
249
|
+
|
|
250
|
+
openSection('Ducking')
|
|
251
|
+
expect(screen.getByRole('switch', { name: 'Enable ducking' })).toHaveAttribute('aria-checked', 'true')
|
|
252
|
+
expect(screen.getByLabelText('Depth')).toHaveValue(-9)
|
|
253
|
+
expect(screen.getByLabelText('Attack')).toHaveValue(0.2)
|
|
254
|
+
expect(screen.getByLabelText('Release')).toHaveValue(0.6)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('label: previews on every keystroke and commits on blur, writing track.label', () => {
|
|
258
|
+
const { onPreviewAudio, onCommitAudio } = renderPanel({ kind: 'audio', track: audioTrack({ label: 'Old' }) })
|
|
259
|
+
const input = screen.getByLabelText('Track label')
|
|
260
|
+
|
|
261
|
+
fireEvent.change(input, { target: { value: 'New name' } })
|
|
262
|
+
expect(onPreviewAudio).toHaveBeenCalledWith(expect.objectContaining({ label: 'New name' }))
|
|
263
|
+
expect(onCommitAudio).not.toHaveBeenCalled()
|
|
264
|
+
|
|
265
|
+
fireEvent.blur(input)
|
|
266
|
+
expect(onCommitAudio).toHaveBeenCalledTimes(1)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('label falls back to the source basename when unset', () => {
|
|
270
|
+
renderPanel({ kind: 'audio', track: audioTrack({ label: undefined, src: '/media/voice/final-track.mp3' }) })
|
|
271
|
+
expect(screen.getByLabelText('Track label')).toHaveValue('final-track.mp3')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('label falls back to the source basename on commit if cleared to blank', () => {
|
|
275
|
+
const { onPreviewAudio, onCommitAudio } = renderPanel({
|
|
276
|
+
kind: 'audio',
|
|
277
|
+
track: audioTrack({ label: 'Old', src: '/x/y/clip.wav' }),
|
|
278
|
+
})
|
|
279
|
+
const input = screen.getByLabelText('Track label')
|
|
280
|
+
|
|
281
|
+
fireEvent.change(input, { target: { value: ' ' } })
|
|
282
|
+
fireEvent.blur(input)
|
|
283
|
+
|
|
284
|
+
expect(onCommitAudio).toHaveBeenCalledTimes(1)
|
|
285
|
+
const resolved = onPreviewAudio.mock.calls[onPreviewAudio.mock.calls.length - 1][0]
|
|
286
|
+
expect(resolved.label).toBe('clip.wav')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('volume: previews on every drag step and commits once, writing track.volume', () => {
|
|
290
|
+
const { onPreviewAudio, onCommitAudio } = renderPanel({ kind: 'audio', track: audioTrack({ volume: 1 }) })
|
|
291
|
+
const slider = screen.getByRole('slider', { name: 'Volume' })
|
|
292
|
+
|
|
293
|
+
fireEvent.change(slider, { target: { value: '0.5' } })
|
|
294
|
+
expect(onPreviewAudio.mock.calls[0][0]).toMatchObject({ id: 'a1', volume: 0.5 })
|
|
295
|
+
expect(onCommitAudio).not.toHaveBeenCalled()
|
|
296
|
+
|
|
297
|
+
fireEvent.pointerUp(slider)
|
|
298
|
+
expect(onCommitAudio).toHaveBeenCalledTimes(1)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('mute: fires exactly one discrete onChangeAudio', () => {
|
|
302
|
+
const { onPreviewAudio, onCommitAudio, onChangeAudio } = renderPanel({ kind: 'audio', track: audioTrack({ muted: false }) })
|
|
303
|
+
|
|
304
|
+
fireEvent.click(screen.getByRole('switch', { name: 'Mute track' }))
|
|
305
|
+
|
|
306
|
+
expect(onChangeAudio).toHaveBeenCalledTimes(1)
|
|
307
|
+
expect(onChangeAudio.mock.calls[0][0]).toMatchObject({ id: 'a1', muted: true })
|
|
308
|
+
expect(onPreviewAudio).not.toHaveBeenCalled()
|
|
309
|
+
expect(onCommitAudio).not.toHaveBeenCalled()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('fades: fade in and fade out each write their own field, preview then commit on blur', () => {
|
|
313
|
+
const { onPreviewAudio, onCommitAudio } = renderPanel({ kind: 'audio', track: audioTrack({ fadeIn: 0, fadeOut: 0 }) })
|
|
314
|
+
openSection('Fades')
|
|
315
|
+
|
|
316
|
+
const fadeIn = screen.getByLabelText('Fade in')
|
|
317
|
+
fireEvent.change(fadeIn, { target: { value: '1.25' } })
|
|
318
|
+
expect(onPreviewAudio.mock.calls[0][0]).toMatchObject({ fadeIn: 1.25, fadeOut: 0 })
|
|
319
|
+
fireEvent.blur(fadeIn)
|
|
320
|
+
expect(onCommitAudio).toHaveBeenCalledTimes(1)
|
|
321
|
+
|
|
322
|
+
const fadeOut = screen.getByLabelText('Fade out')
|
|
323
|
+
fireEvent.change(fadeOut, { target: { value: '2' } })
|
|
324
|
+
const lastPreview = onPreviewAudio.mock.calls[onPreviewAudio.mock.calls.length - 1][0]
|
|
325
|
+
expect(lastPreview).toMatchObject({ fadeOut: 2 })
|
|
326
|
+
fireEvent.blur(fadeOut)
|
|
327
|
+
expect(onCommitAudio).toHaveBeenCalledTimes(2)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('fade in stepper: fires exactly one discrete onChangeAudio through clampAudioPatch, no preview/commit', () => {
|
|
331
|
+
// The largest new adoption site for the stepper contract, and the only
|
|
332
|
+
// one routing through `clampAudioPatch` (see AudioSection's comment on
|
|
333
|
+
// why the fade rows' own `Math.max(0, v)` is redundant with it). A
|
|
334
|
+
// stepper click is a DISCRETE edit — one final change, one undo entry —
|
|
335
|
+
// never a preview/commit pair.
|
|
336
|
+
const { onPreviewAudio, onCommitAudio, onChangeAudio } = renderPanel({ kind: 'audio', track: audioTrack({ fadeIn: 0 }) })
|
|
337
|
+
openSection('Fades')
|
|
338
|
+
|
|
339
|
+
fireEvent.click(screen.getByRole('button', { name: 'Increase Fade in' }))
|
|
340
|
+
|
|
341
|
+
expect(onChangeAudio).toHaveBeenCalledTimes(1)
|
|
342
|
+
expect(onChangeAudio.mock.calls[0][0]).toMatchObject({ fadeIn: 0.1 })
|
|
343
|
+
expect(onPreviewAudio).not.toHaveBeenCalled()
|
|
344
|
+
expect(onCommitAudio).not.toHaveBeenCalled()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('trim: in point and out point each write their own field', () => {
|
|
348
|
+
const { onPreviewAudio, onCommitAudio } = renderPanel({ kind: 'audio', track: audioTrack({ inPoint: 0, outPoint: 10 }) })
|
|
349
|
+
|
|
350
|
+
const inPoint = screen.getByLabelText('In point')
|
|
351
|
+
fireEvent.change(inPoint, { target: { value: '3' } })
|
|
352
|
+
expect(onPreviewAudio.mock.calls[0][0]).toMatchObject({ inPoint: 3, outPoint: 10 })
|
|
353
|
+
fireEvent.blur(inPoint)
|
|
354
|
+
|
|
355
|
+
const outPoint = screen.getByLabelText('Out point')
|
|
356
|
+
fireEvent.change(outPoint, { target: { value: '9' } })
|
|
357
|
+
const lastPreview = onPreviewAudio.mock.calls[onPreviewAudio.mock.calls.length - 1][0]
|
|
358
|
+
expect(lastPreview).toMatchObject({ outPoint: 9 })
|
|
359
|
+
fireEvent.blur(outPoint)
|
|
360
|
+
|
|
361
|
+
expect(onCommitAudio).toHaveBeenCalledTimes(2)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe('ducking', () => {
|
|
365
|
+
it('numeric fields are disabled until ducking is enabled', () => {
|
|
366
|
+
renderPanel({ kind: 'audio', track: audioTrack({ ducking: undefined }) })
|
|
367
|
+
openSection('Ducking')
|
|
368
|
+
|
|
369
|
+
expect(screen.getByLabelText('Depth')).toBeDisabled()
|
|
370
|
+
expect(screen.getByLabelText('Attack')).toBeDisabled()
|
|
371
|
+
expect(screen.getByLabelText('Release')).toBeDisabled()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('enabling seeds the documented defaults (-12 / 0.3 / 0.5) rather than a half-empty object', () => {
|
|
375
|
+
const { onChangeAudio } = renderPanel({ kind: 'audio', track: audioTrack({ ducking: undefined }) })
|
|
376
|
+
openSection('Ducking')
|
|
377
|
+
|
|
378
|
+
fireEvent.click(screen.getByRole('switch', { name: 'Enable ducking' }))
|
|
379
|
+
|
|
380
|
+
expect(onChangeAudio).toHaveBeenCalledTimes(1)
|
|
381
|
+
expect(onChangeAudio.mock.calls[0][0]).toMatchObject({
|
|
382
|
+
ducking: { enabled: true, depth: -12, attack: 0.3, release: 0.5 },
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('disabling keeps the tuned depth/attack/release so re-enabling restores them', () => {
|
|
387
|
+
const { onChangeAudio } = renderPanel({
|
|
388
|
+
kind: 'audio',
|
|
389
|
+
track: audioTrack({ ducking: { enabled: true, depth: -20, attack: 0.9, release: 1.2 } }),
|
|
390
|
+
})
|
|
391
|
+
openSection('Ducking')
|
|
392
|
+
|
|
393
|
+
fireEvent.click(screen.getByRole('switch', { name: 'Enable ducking' }))
|
|
394
|
+
|
|
395
|
+
expect(onChangeAudio.mock.calls[0][0]).toMatchObject({
|
|
396
|
+
ducking: { enabled: false, depth: -20, attack: 0.9, release: 1.2 },
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('depth/attack/release write the nested track.ducking object, preview then commit on blur', () => {
|
|
401
|
+
const { onPreviewAudio, onCommitAudio } = renderPanel({
|
|
402
|
+
kind: 'audio',
|
|
403
|
+
track: audioTrack({ ducking: { enabled: true, depth: -12, attack: 0.3, release: 0.5 } }),
|
|
404
|
+
})
|
|
405
|
+
openSection('Ducking')
|
|
406
|
+
|
|
407
|
+
const depth = screen.getByLabelText('Depth')
|
|
408
|
+
fireEvent.change(depth, { target: { value: '-6' } })
|
|
409
|
+
expect(onPreviewAudio.mock.calls[0][0]).toMatchObject({
|
|
410
|
+
ducking: { enabled: true, depth: -6, attack: 0.3, release: 0.5 },
|
|
411
|
+
})
|
|
412
|
+
fireEvent.blur(depth)
|
|
413
|
+
expect(onCommitAudio).toHaveBeenCalledTimes(1)
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('has no delete control anywhere in the panel (deliberate — see the AudioSection doc comment)', () => {
|
|
418
|
+
renderPanel({ kind: 'audio', track: audioTrack() })
|
|
419
|
+
openSection('Fades')
|
|
420
|
+
openSection('Ducking')
|
|
421
|
+
|
|
422
|
+
expect(screen.queryByRole('button', { name: /delete/i })).toBeNull()
|
|
423
|
+
expect(screen.queryByText(/delete/i)).toBeNull()
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
describe('ClipPropertiesPanel — mid-typed value survives an unrelated re-render', () => {
|
|
428
|
+
it('a text field (Track label) keeps its draft when the panel re-renders with an unrelated change', () => {
|
|
429
|
+
const track = audioTrack({ label: 'Original' })
|
|
430
|
+
const { rerenderWith } = renderPanel({ kind: 'audio', track })
|
|
431
|
+
const input = screen.getByLabelText('Track label')
|
|
432
|
+
|
|
433
|
+
fireEvent.change(input, { target: { value: 'Ne' } }) // mid-typing "Ne" of "New name"
|
|
434
|
+
expect(input).toHaveValue('Ne')
|
|
435
|
+
|
|
436
|
+
// The host re-renders the panel — e.g. an unrelated volume preview
|
|
437
|
+
// committed elsewhere — while the operator is still mid-edit.
|
|
438
|
+
rerenderWith({ kind: 'audio', track: { ...track, volume: 1.9 } })
|
|
439
|
+
expect(input).toHaveValue('Ne')
|
|
440
|
+
|
|
441
|
+
fireEvent.change(input, { target: { value: 'New name' } })
|
|
442
|
+
expect(input).toHaveValue('New name')
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('a number field (Fade in) keeps its draft when the panel re-renders with an unrelated change', () => {
|
|
446
|
+
const track = audioTrack({ fadeIn: 0 })
|
|
447
|
+
const { rerenderWith } = renderPanel({ kind: 'audio', track })
|
|
448
|
+
openSection('Fades')
|
|
449
|
+
const input = screen.getByLabelText('Fade in')
|
|
450
|
+
|
|
451
|
+
fireEvent.change(input, { target: { value: '1' } }) // mid-typing "1" of "1.5"
|
|
452
|
+
expect(input).toHaveValue(1)
|
|
453
|
+
|
|
454
|
+
rerenderWith({ kind: 'audio', track: { ...track, volume: 1.9 } })
|
|
455
|
+
expect(input).toHaveValue(1)
|
|
456
|
+
|
|
457
|
+
fireEvent.change(input, { target: { value: '1.5' } })
|
|
458
|
+
expect(input).toHaveValue(1.5)
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
describe('ClipPropertiesPanel — speed is video-only', () => {
|
|
463
|
+
it('hides the Speed control for a non-video clip', () => {
|
|
464
|
+
// `setClipSpeed` early-returns for a non-video item, so a Speed slider on
|
|
465
|
+
// an image would be a control that silently does nothing. The modal this
|
|
466
|
+
// panel replaces gated the same way.
|
|
467
|
+
const image = { id: 'i1', type: 'image', src: 'p.png', start: 0, end: 4 } as VisualItem
|
|
468
|
+
render(
|
|
469
|
+
<ClipPropertiesPanel
|
|
470
|
+
selection={{ kind: 'clip', item: image }}
|
|
471
|
+
onPreviewClip={vi.fn()}
|
|
472
|
+
onCommitClip={vi.fn()}
|
|
473
|
+
onChangeClip={vi.fn()}
|
|
474
|
+
onPreviewAudio={vi.fn()}
|
|
475
|
+
onCommitAudio={vi.fn()}
|
|
476
|
+
onChangeAudio={vi.fn()}
|
|
477
|
+
/>,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
expect(screen.queryByRole('button', { name: 'Speed' })).toBeNull()
|
|
481
|
+
expect(screen.queryByLabelText('Speed')).toBeNull()
|
|
482
|
+
// The video-agnostic controls are still there, on the Volume tab (the
|
|
483
|
+
// only tab this image selection offers besides Transform, so it's also
|
|
484
|
+
// the default active tab here).
|
|
485
|
+
openClipTab('Volume')
|
|
486
|
+
expect(screen.getByLabelText('Mute clip')).toBeTruthy()
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
describe('ClipPropertiesPanel — clip tabs', () => {
|
|
491
|
+
it('defaults to the Transform tab, showing transformSlot content on first render', () => {
|
|
492
|
+
renderPanel({ kind: 'clip', item: clipItem() }, { transformSlot: <div data-testid="transform-slot">Transform body</div> })
|
|
493
|
+
|
|
494
|
+
expect(screen.getByTestId('transform-slot')).toBeTruthy()
|
|
495
|
+
expect(screen.getByRole('button', { name: 'Transform' })).toHaveAttribute('aria-pressed', 'true')
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('an image clip offers no Speed, Crop, or Generate tabs', () => {
|
|
499
|
+
// Crop/Generate are gated on the CALLER supplying onOpenCrop/
|
|
500
|
+
// generationSlot at all (the host encodes the "images don't generate or
|
|
501
|
+
// crop" rule) — this fixture mirrors a host that, for an image, simply
|
|
502
|
+
// doesn't offer those slots.
|
|
503
|
+
renderPanel({ kind: 'clip', item: clipItem({ type: 'image' }) }, { transformSlot: <div>T</div> })
|
|
504
|
+
|
|
505
|
+
expect(screen.getByRole('button', { name: 'Transform' })).toBeTruthy()
|
|
506
|
+
expect(screen.getByRole('button', { name: 'Volume' })).toBeTruthy()
|
|
507
|
+
expect(screen.queryByRole('button', { name: 'Speed' })).toBeNull()
|
|
508
|
+
expect(screen.queryByRole('button', { name: 'Crop' })).toBeNull()
|
|
509
|
+
expect(screen.queryByRole('button', { name: 'Generate' })).toBeNull()
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('a main-track video with both slots offers all five tabs, in order', () => {
|
|
513
|
+
renderPanel(
|
|
514
|
+
{ kind: 'clip', item: clipItem({ type: 'video' }) },
|
|
515
|
+
{ transformSlot: <div>T</div>, onOpenCrop: vi.fn(), generationSlot: <div>G</div> },
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
const names = screen.getAllByRole('button').map(b => b.textContent)
|
|
519
|
+
expect(names).toEqual(['Transform', 'Speed', 'Volume', 'Crop', 'Generate'])
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('renders no Crop tab when onOpenCrop is omitted', () => {
|
|
523
|
+
renderPanel({ kind: 'clip', item: clipItem() }, { transformSlot: <div>T</div> })
|
|
524
|
+
expect(screen.queryByRole('button', { name: 'Crop' })).toBeNull()
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it("clicking the Crop tab's button calls onOpenCrop", () => {
|
|
528
|
+
const onOpenCrop = vi.fn()
|
|
529
|
+
renderPanel({ kind: 'clip', item: clipItem() }, { transformSlot: <div>T</div>, onOpenCrop })
|
|
530
|
+
|
|
531
|
+
openClipTab('Crop')
|
|
532
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open crop tool' }))
|
|
533
|
+
expect(onOpenCrop).toHaveBeenCalledTimes(1)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
function StatefulSlot() {
|
|
537
|
+
const [value, setValue] = useState('')
|
|
538
|
+
return <input aria-label="Gen prompt" value={value} onChange={e => setValue(e.target.value)} />
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
it("switching tabs away from Generate and back preserves the generation slot's internal state", () => {
|
|
542
|
+
renderPanel(
|
|
543
|
+
{ kind: 'clip', item: clipItem() },
|
|
544
|
+
{ transformSlot: <div>T</div>, generationSlot: <StatefulSlot /> },
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
openClipTab('Generate')
|
|
548
|
+
fireEvent.change(screen.getByLabelText('Gen prompt'), { target: { value: 'a sunset over water' } })
|
|
549
|
+
expect(screen.getByLabelText('Gen prompt')).toHaveValue('a sunset over water')
|
|
550
|
+
|
|
551
|
+
// Switch away, then back. If the Generate tab body were unmounted on
|
|
552
|
+
// switch-away (a plain conditional render instead of lazy-mount-then-
|
|
553
|
+
// keep-mounted), StatefulSlot's `useState('')` initializer would re-run
|
|
554
|
+
// on remount and this value would be lost.
|
|
555
|
+
openClipTab('Volume')
|
|
556
|
+
openClipTab('Generate')
|
|
557
|
+
expect(screen.getByLabelText('Gen prompt')).toHaveValue('a sunset over water')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('falls back to Transform when the persisted tab id is not in the current tab set', () => {
|
|
561
|
+
// Simulates the operator having 'generate' active on a different clip,
|
|
562
|
+
// then selecting one whose host doesn't offer a generationSlot.
|
|
563
|
+
window.localStorage.setItem('montaj.editor.clipPanelTab', JSON.stringify('generate'))
|
|
564
|
+
renderPanel({ kind: 'clip', item: clipItem() }, { transformSlot: <div data-testid="transform-slot">T</div> })
|
|
565
|
+
|
|
566
|
+
expect(screen.getByTestId('transform-slot')).toBeTruthy()
|
|
567
|
+
expect(screen.getByRole('button', { name: 'Transform' })).toHaveAttribute('aria-pressed', 'true')
|
|
568
|
+
})
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
describe('ClipPropertiesPanel — audio timeline position (modal parity)', () => {
|
|
572
|
+
it('renders Start and End and writes them as plain values', () => {
|
|
573
|
+
const track = { id: 'a1', src: '/m/bed.mp3', start: 2, end: 30, sourceDuration: 30 } as AudioTrack
|
|
574
|
+
const onPreviewAudio = vi.fn()
|
|
575
|
+
render(
|
|
576
|
+
<ClipPropertiesPanel
|
|
577
|
+
selection={{ kind: 'audio', track }}
|
|
578
|
+
onPreviewClip={vi.fn()}
|
|
579
|
+
onCommitClip={vi.fn()}
|
|
580
|
+
onChangeClip={vi.fn()}
|
|
581
|
+
onPreviewAudio={onPreviewAudio}
|
|
582
|
+
onCommitAudio={vi.fn()}
|
|
583
|
+
onChangeAudio={vi.fn()}
|
|
584
|
+
/>,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
expect(screen.getByLabelText('Start')).toHaveValue(2)
|
|
588
|
+
expect(screen.getByLabelText('End')).toHaveValue(30)
|
|
589
|
+
|
|
590
|
+
fireEvent.change(screen.getByLabelText('Start'), { target: { value: '5' } })
|
|
591
|
+
expect(onPreviewAudio).toHaveBeenLastCalledWith(expect.objectContaining({ start: 5 }))
|
|
592
|
+
|
|
593
|
+
fireEvent.change(screen.getByLabelText('End'), { target: { value: '25' } })
|
|
594
|
+
expect(onPreviewAudio).toHaveBeenLastCalledWith(expect.objectContaining({ end: 25 }))
|
|
595
|
+
})
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
describe('ClipPropertiesPanel — audio validation (parity with the retired modal)', () => {
|
|
599
|
+
function audioTrack(over: Partial<AudioTrack> = {}): AudioTrack {
|
|
600
|
+
return { id: 'a1', src: '/m/bed.mp3', start: 2, end: 30, sourceDuration: 40, ...over } as AudioTrack
|
|
601
|
+
}
|
|
602
|
+
function renderAudio(track: AudioTrack) {
|
|
603
|
+
const onPreviewAudio = vi.fn()
|
|
604
|
+
render(
|
|
605
|
+
<ClipPropertiesPanel
|
|
606
|
+
selection={{ kind: 'audio', track }}
|
|
607
|
+
onPreviewClip={vi.fn()} onCommitClip={vi.fn()} onChangeClip={vi.fn()}
|
|
608
|
+
onPreviewAudio={onPreviewAudio} onCommitAudio={vi.fn()} onChangeAudio={vi.fn()}
|
|
609
|
+
/>,
|
|
610
|
+
)
|
|
611
|
+
return { onPreviewAudio }
|
|
612
|
+
}
|
|
613
|
+
const last = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls[fn.mock.calls.length - 1][0] as AudioTrack
|
|
614
|
+
|
|
615
|
+
it('never lets End land at or before Start (a negative-duration track)', () => {
|
|
616
|
+
const { onPreviewAudio } = renderAudio(audioTrack())
|
|
617
|
+
fireEvent.change(screen.getByLabelText('End'), { target: { value: '1' } }) // before start=2
|
|
618
|
+
expect(last(onPreviewAudio).end).toBeGreaterThan(2)
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it('never lets Start land at or after End', () => {
|
|
622
|
+
const { onPreviewAudio } = renderAudio(audioTrack())
|
|
623
|
+
fireEvent.change(screen.getByLabelText('Start'), { target: { value: '99' } })
|
|
624
|
+
expect(last(onPreviewAudio).start).toBeLessThan(30)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('clamps Out point to the source duration', () => {
|
|
628
|
+
const { onPreviewAudio } = renderAudio(audioTrack())
|
|
629
|
+
fireEvent.change(screen.getByLabelText('Out point'), { target: { value: '999' } })
|
|
630
|
+
expect(last(onPreviewAudio).outPoint).toBe(40)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('never lets In point cross Out point', () => {
|
|
634
|
+
const { onPreviewAudio } = renderAudio(audioTrack({ outPoint: 10 }))
|
|
635
|
+
fireEvent.change(screen.getByLabelText('In point'), { target: { value: '25' } })
|
|
636
|
+
expect(last(onPreviewAudio).inPoint).toBeLessThanOrEqual(10)
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it('caps a fade at the modal\'s 5s ceiling rather than leaving it unbounded', () => {
|
|
640
|
+
const { onPreviewAudio } = renderAudio(audioTrack())
|
|
641
|
+
fireEvent.click(screen.getByRole('button', { name: 'Fades' })) // open the group
|
|
642
|
+
fireEvent.change(screen.getByLabelText('Fade in'), { target: { value: '999' } })
|
|
643
|
+
expect(last(onPreviewAudio).fadeIn).toBe(5)
|
|
644
|
+
})
|
|
645
|
+
})
|