@bycrux/editor 0.11.2 → 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 +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- 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 +601 -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 +137 -0
- 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 +2229 -243
- 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/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -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,765 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest'
|
|
3
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
4
|
+
import type { EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
5
|
+
import VideoEditor from '../VideoEditor'
|
|
6
|
+
import { installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
|
|
7
|
+
|
|
8
|
+
// ── The CapCut layout's right properties panel ───────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// The clip/audio inspector used to be a host-rendered MODAL, opened by a
|
|
11
|
+
// double-click on the timeline and wired through the `renderClipInspector`
|
|
12
|
+
// render-prop (now removed). It is a persistent column now: single-click
|
|
13
|
+
// selection populates it, and the column is ALWAYS mounted so the preview never
|
|
14
|
+
// resizes as the operator clicks from clip to clip.
|
|
15
|
+
//
|
|
16
|
+
// Two behaviours here can only be tested at THIS seam, not in
|
|
17
|
+
// ClipPropertiesPanel's own suite:
|
|
18
|
+
//
|
|
19
|
+
// 1. RIPPLE (Task G). A speed-up shrinks a clip and leaves a gap. The retired
|
|
20
|
+
// modal closed it via `collapseGaps` when the magnet was on; the panel
|
|
21
|
+
// cannot — it only ever sees the one item, and collapsing gaps moves that
|
|
22
|
+
// item's SIBLINGS. So VideoEditor's commit handler owns it.
|
|
23
|
+
// 2. The DRAG FEEDBACK LOOP (Task H). `SpeedControl` reads the slider's own
|
|
24
|
+
// DOM value at pointerup. If the previewed item is not fed back into the
|
|
25
|
+
// panel's `selection` prop DURING the drag, React reverts the controlled
|
|
26
|
+
// input and commit reads the pre-drag value.
|
|
27
|
+
|
|
28
|
+
const MEDIA_PANEL = <div data-testid="media-panel">Footage bin</div>
|
|
29
|
+
|
|
30
|
+
/** Two CONTIGUOUS video clips: clip-0 [0,10] holding a 10s source window at 1×,
|
|
31
|
+
* clip-1 [10,14] butted against it. No gap to start with — so turning the
|
|
32
|
+
* magnet on is a no-op (`handleRippleToggle` collapses on enable) and the only
|
|
33
|
+
* gap in the test is the one the speed change itself opens. */
|
|
34
|
+
function makeProject(overrides: Partial<Project> = {}): Project {
|
|
35
|
+
return {
|
|
36
|
+
id: 'vid-1',
|
|
37
|
+
name: 'Test Video',
|
|
38
|
+
status: 'draft',
|
|
39
|
+
editingPrompt: '',
|
|
40
|
+
projectType: 'video',
|
|
41
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
42
|
+
tracks: [
|
|
43
|
+
[
|
|
44
|
+
{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110 },
|
|
45
|
+
{ id: 'clip-1', type: 'video', src: 'b.mp4', start: 10, end: 14, inPoint: 0, outPoint: 4 },
|
|
46
|
+
],
|
|
47
|
+
],
|
|
48
|
+
audio: { tracks: [] },
|
|
49
|
+
assets: [],
|
|
50
|
+
...overrides,
|
|
51
|
+
} as Project
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeFakeAdapter(project: Project): EditorAdapter<Project> {
|
|
55
|
+
return {
|
|
56
|
+
loadProject: vi.fn(async () => project),
|
|
57
|
+
saveProject: vi.fn(async () => {}),
|
|
58
|
+
subscribe: () => () => {},
|
|
59
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
60
|
+
yield { type: 'done', outputPath: '/out.mp4' }
|
|
61
|
+
},
|
|
62
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
63
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
64
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
65
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
66
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
67
|
+
fileUrl: (path: string) => path,
|
|
68
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
69
|
+
restoreVersion: vi.fn(async () => project),
|
|
70
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
71
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
72
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
73
|
+
} as unknown as EditorAdapter<Project>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The project body of the LAST `saveProject` call — what a commit persisted. */
|
|
77
|
+
function lastSaved(adapter: EditorAdapter<Project>): Project {
|
|
78
|
+
const calls = (adapter.saveProject as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
|
79
|
+
return calls[calls.length - 1][1] as Project
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One of the CLIP panel's own tab buttons — Transform · Speed · Volume · Crop
|
|
84
|
+
* · Generate — resolved INSIDE that tab strip's `role="group"`, the same
|
|
85
|
+
* scoping `overlayTab` below uses for the overlay panel's Content/Transform
|
|
86
|
+
* pair, and for the same defensive reason: a tab's own body can grow a
|
|
87
|
+
* control whose accessible name would otherwise collide with a tab label.
|
|
88
|
+
*/
|
|
89
|
+
async function clipTab(name: 'Transform' | 'Speed' | 'Volume' | 'Crop' | 'Generate'): Promise<HTMLButtonElement> {
|
|
90
|
+
const strip = await screen.findByRole('group', { name: 'Clip panel view' })
|
|
91
|
+
const button = Array.from(strip.querySelectorAll('button')).find(b => b.textContent === name)
|
|
92
|
+
if (!button) throw new Error(`no "${name}" tab in the clip panel strip`)
|
|
93
|
+
return button
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The tab labels the clip panel currently offers, in TabNav's own render
|
|
97
|
+
* order — for asserting a WHOLE tab set (e.g. "just Transform and Volume for
|
|
98
|
+
* an image clip") in one line instead of one assertion per missing tab. */
|
|
99
|
+
function clipTabNames(strip: HTMLElement): string[] {
|
|
100
|
+
return Array.from(strip.querySelectorAll('button')).map(b => b.textContent ?? '')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The Crop tab's OWN body button ("Open crop tool", a plain-text button with
|
|
105
|
+
* no `aria-label`) — distinct from the crop TOOLBAR button in the controls
|
|
106
|
+
* bar, which is an icon-only button carrying `aria-label="Crop source"`. The
|
|
107
|
+
* two used to share the same visible/accessible text ("Crop source"); the
|
|
108
|
+
* body button's label was changed to resolve that collision, so scoping to
|
|
109
|
+
* the clip panel's own subtree (the tab strip's parent) is no longer needed
|
|
110
|
+
* to disambiguate — kept anyway as good practice for a panel-specific query.
|
|
111
|
+
*/
|
|
112
|
+
async function cropTabBodyButton(): Promise<HTMLButtonElement> {
|
|
113
|
+
const strip = await screen.findByRole('group', { name: 'Clip panel view' })
|
|
114
|
+
const scope = strip.parentElement as HTMLElement
|
|
115
|
+
return waitFor(() => {
|
|
116
|
+
const found = Array.from(scope.querySelectorAll('button')).find(b => b.textContent === 'Open crop tool')
|
|
117
|
+
if (!found) throw new Error('no "Open crop tool" button in the clip panel body')
|
|
118
|
+
return found
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderCapCut(
|
|
123
|
+
project: Project,
|
|
124
|
+
adapter: EditorAdapter<Project>,
|
|
125
|
+
{ slots, ...props }: { slots?: Record<string, unknown> } & Record<string, unknown> = {},
|
|
126
|
+
) {
|
|
127
|
+
return render(
|
|
128
|
+
<VideoEditor
|
|
129
|
+
project={project}
|
|
130
|
+
adapter={adapter}
|
|
131
|
+
onProjectChange={vi.fn()}
|
|
132
|
+
slots={{ mediaPanel: MEDIA_PANEL, ...slots }}
|
|
133
|
+
{...props}
|
|
134
|
+
/>,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
// The left panel persists its active tab; a leftover value from an earlier
|
|
140
|
+
// test would decide which tab this one opens on.
|
|
141
|
+
localStorage.clear()
|
|
142
|
+
// The caption panel now has Style / Captions sub-tabs defaulting to Style;
|
|
143
|
+
// the CapCut-left-panel test wants the transcript ('caption one') visible.
|
|
144
|
+
localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
|
|
145
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
146
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
147
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
148
|
+
observe() {}
|
|
149
|
+
unobserve() {}
|
|
150
|
+
disconnect() {}
|
|
151
|
+
}
|
|
152
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
153
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
154
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
155
|
+
state = 'running'
|
|
156
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
157
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
158
|
+
get destination() { return {} }
|
|
159
|
+
close() {}
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
afterEach(() => vi.restoreAllMocks())
|
|
163
|
+
|
|
164
|
+
describe('VideoEditor — CapCut right properties panel', () => {
|
|
165
|
+
it('always renders the column, showing the generic empty state when nothing is selected', async () => {
|
|
166
|
+
const project = makeProject()
|
|
167
|
+
renderCapCut(project, makeFakeAdapter(project))
|
|
168
|
+
|
|
169
|
+
// The column's divider is the structural tell that it mounted at all —
|
|
170
|
+
// with NOTHING selected, which is the whole point of "always visible".
|
|
171
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
172
|
+
expect(screen.getByText('Select an element')).toBeTruthy()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('renders the host-supplied propertiesEmptyState slot instead of the default when nothing is selected', async () => {
|
|
176
|
+
const project = makeProject()
|
|
177
|
+
renderCapCut(project, makeFakeAdapter(project), {
|
|
178
|
+
slots: { propertiesEmptyState: <div data-testid="host-empty">host empty</div> },
|
|
179
|
+
})
|
|
180
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
181
|
+
expect(screen.getByTestId('host-empty')).toBeTruthy()
|
|
182
|
+
// The host node REPLACES the generic default, it is not stacked with it.
|
|
183
|
+
expect(screen.queryByText('Select an element')).toBeNull()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('swaps in the clip properties when a video clip is selected, defaulting to Transform', async () => {
|
|
187
|
+
onTestFinished(installCanvasHarness())
|
|
188
|
+
const project = makeProject()
|
|
189
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
190
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
191
|
+
|
|
192
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
193
|
+
|
|
194
|
+
// Transform is the default tab, and its body is the shared OverlayInspector
|
|
195
|
+
// — the same control the overlay branch's Transform tab uses.
|
|
196
|
+
expect((await clipTab('Transform')).getAttribute('aria-pressed')).toBe('true')
|
|
197
|
+
expect(await screen.findByLabelText('Scale')).toBeTruthy()
|
|
198
|
+
// Exactly one branch renders — the empty state is gone, not stacked.
|
|
199
|
+
expect(screen.queryByText('Select an element')).toBeNull()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('switching to the Speed tab reveals the speed control (video only)', async () => {
|
|
203
|
+
onTestFinished(installCanvasHarness())
|
|
204
|
+
const project = makeProject()
|
|
205
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
206
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
207
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
208
|
+
await screen.findByLabelText('Scale')
|
|
209
|
+
|
|
210
|
+
fireEvent.click(await clipTab('Speed'))
|
|
211
|
+
|
|
212
|
+
// Not asserting the Transform body is gone: ClipTabs keeps every opened
|
|
213
|
+
// tab MOUNTED (only hiding the inactive ones) once it has been shown, so
|
|
214
|
+
// the Transform inputs are still in the DOM here — just hidden, and that
|
|
215
|
+
// is by design (see ClipPropertiesPanel's "Lazy mount, then KEEP MOUNTED"
|
|
216
|
+
// comment), not a leak this test should chase.
|
|
217
|
+
expect(await screen.findByRole('slider', { name: 'Speed' })).toBeTruthy()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('switching to the Volume tab reveals volume and mute controls', async () => {
|
|
221
|
+
onTestFinished(installCanvasHarness())
|
|
222
|
+
const project = makeProject()
|
|
223
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
224
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
225
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
226
|
+
await screen.findByLabelText('Scale')
|
|
227
|
+
|
|
228
|
+
fireEvent.click(await clipTab('Volume'))
|
|
229
|
+
|
|
230
|
+
expect(await screen.findByLabelText('Mute clip')).toBeTruthy()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('swaps in the audio-track properties when an audio track is selected', async () => {
|
|
234
|
+
onTestFinished(installCanvasHarness())
|
|
235
|
+
const project = makeProject({
|
|
236
|
+
audio: { tracks: [{ id: 'a0', src: 'vo.wav', start: 0, end: 4 }] },
|
|
237
|
+
} as unknown as Partial<Project>)
|
|
238
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
239
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
240
|
+
|
|
241
|
+
selectCanvasItem(container, project, { id: 'a0' })
|
|
242
|
+
|
|
243
|
+
expect(await screen.findByLabelText('Track label')).toBeTruthy()
|
|
244
|
+
expect(screen.getByLabelText('Mute track')).toBeTruthy()
|
|
245
|
+
// Speed is video-only, so an audio selection must not carry a Speed slider.
|
|
246
|
+
expect(screen.queryByRole('slider', { name: 'Speed' })).toBeNull()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('calls renderGenerationPanel with the SELECTED clip\'s id, and its node mounts in the Generate tab', async () => {
|
|
250
|
+
onTestFinished(installCanvasHarness())
|
|
251
|
+
// Both `regenEnabled` and the clip's own frozen `generation` provenance
|
|
252
|
+
// have to be present — the Generate tab is gated on the clip actually
|
|
253
|
+
// being a regenerable generation, not merely on being a video.
|
|
254
|
+
const project = makeProject({
|
|
255
|
+
tracks: [[
|
|
256
|
+
{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110,
|
|
257
|
+
generation: { sceneId: 'sc-1', prompt: 'a wide shot', provider: 'kling' } },
|
|
258
|
+
{ id: 'clip-1', type: 'video', src: 'b.mp4', start: 10, end: 14, inPoint: 0, outPoint: 4 },
|
|
259
|
+
]],
|
|
260
|
+
} as unknown as Partial<Project>)
|
|
261
|
+
const renderGenerationPanel = vi.fn(({ clipId }: { clipId: string }) => (
|
|
262
|
+
<div data-testid="generation-panel">Regenerate {clipId}</div>
|
|
263
|
+
))
|
|
264
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project), { renderGenerationPanel, regenEnabled: true })
|
|
265
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
266
|
+
|
|
267
|
+
// Not called with nothing selected — it hangs off the clip branch, and the
|
|
268
|
+
// seam is per-clip, so there is no clip id to call it with yet.
|
|
269
|
+
expect(screen.queryByTestId('generation-panel')).toBeNull()
|
|
270
|
+
expect(renderGenerationPanel).not.toHaveBeenCalled()
|
|
271
|
+
|
|
272
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
273
|
+
|
|
274
|
+
// The render-prop is called as soon as the clip is selected — it builds
|
|
275
|
+
// the slot VideoEditor hands to the tab shell, independent of which tab is
|
|
276
|
+
// showing. The whole point of the render-prop over a static node: the
|
|
277
|
+
// editor owns selection, so it tells the host WHICH clip to draw for.
|
|
278
|
+
await waitFor(() => expect(renderGenerationPanel).toHaveBeenCalledWith({ clipId: 'clip-0' }))
|
|
279
|
+
// But the Generate tab's body is lazy-mounted — it does not reach the DOM
|
|
280
|
+
// until the operator actually opens that tab.
|
|
281
|
+
expect(screen.queryByTestId('generation-panel')).toBeNull()
|
|
282
|
+
|
|
283
|
+
fireEvent.click(await clipTab('Generate'))
|
|
284
|
+
|
|
285
|
+
expect(await screen.findByTestId('generation-panel')).toBeTruthy()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('remounts the Generate tab body on a clip-to-clip switch, rather than reconciling it in place with the OLD clip\'s content', async () => {
|
|
289
|
+
onTestFinished(installCanvasHarness())
|
|
290
|
+
// Two DIFFERENT generated clips, both eligible for the Generate tab.
|
|
291
|
+
const project = makeProject({
|
|
292
|
+
tracks: [[
|
|
293
|
+
{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110,
|
|
294
|
+
generation: { sceneId: 'sc-1', prompt: 'a wide shot', provider: 'kling' } },
|
|
295
|
+
{ id: 'clip-1', type: 'video', src: 'b.mp4', start: 10, end: 20, inPoint: 0, outPoint: 10,
|
|
296
|
+
generation: { sceneId: 'sc-2', prompt: 'a close-up', provider: 'kling' } },
|
|
297
|
+
]],
|
|
298
|
+
} as unknown as Partial<Project>)
|
|
299
|
+
|
|
300
|
+
// A stand-in for the host's real regen form. Like the real one, it seeds
|
|
301
|
+
// state from `clipId` in a `useState` INITIALIZER — which React only
|
|
302
|
+
// ever runs on a component instance's initial mount, never on a
|
|
303
|
+
// re-render with new props. If `generationSlot` were reconciled IN PLACE
|
|
304
|
+
// across the clip switch (missing or wrong `key`), this label would keep
|
|
305
|
+
// reading the FIRST clip it ever mounted for even though
|
|
306
|
+
// `renderGenerationPanel` had already been called with the new clip's
|
|
307
|
+
// id — the exact stale-form bug (wrong clip's prompt queued for
|
|
308
|
+
// regeneration, spending credits on the wrong content) the
|
|
309
|
+
// `key={clipSelection.item.id}` guard exists to prevent. Calling the
|
|
310
|
+
// render-prop with the right id is necessary but not sufficient to catch
|
|
311
|
+
// that bug; only the rendered content proves the subtree actually
|
|
312
|
+
// remounted.
|
|
313
|
+
function FakeRegenForm({ clipId }: { clipId: string }) {
|
|
314
|
+
const [seededFor] = useState(clipId)
|
|
315
|
+
return <div data-testid="generation-panel">seeded for {seededFor}</div>
|
|
316
|
+
}
|
|
317
|
+
const renderGenerationPanel = vi.fn(({ clipId }: { clipId: string }) => <FakeRegenForm clipId={clipId} />)
|
|
318
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project), { renderGenerationPanel, regenEnabled: true })
|
|
319
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
320
|
+
|
|
321
|
+
selectCanvasItem(container, project, { id: 'clip-0' })
|
|
322
|
+
await waitFor(() => expect(renderGenerationPanel).toHaveBeenCalledWith({ clipId: 'clip-0' }))
|
|
323
|
+
fireEvent.click(await clipTab('Generate'))
|
|
324
|
+
expect(await screen.findByTestId('generation-panel')).toHaveTextContent('seeded for clip-0')
|
|
325
|
+
|
|
326
|
+
selectCanvasItem(container, project, { id: 'clip-1' })
|
|
327
|
+
|
|
328
|
+
// The render-prop is called with the NEW clip's id — this much would
|
|
329
|
+
// pass even with the key missing.
|
|
330
|
+
await waitFor(() => expect(renderGenerationPanel).toHaveBeenCalledWith({ clipId: 'clip-1' }))
|
|
331
|
+
// The OBSERVABLE remount: the seeded label must now read clip-1. Without
|
|
332
|
+
// the key, React would reconcile `FakeRegenForm` in place, its `useState`
|
|
333
|
+
// initializer would NOT re-run, and this would still read "seeded for
|
|
334
|
+
// clip-0" forever — the credit-spending bug the key guards against.
|
|
335
|
+
expect(await screen.findByTestId('generation-panel')).toHaveTextContent('seeded for clip-1')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('does not call renderGenerationPanel for an audio track (generation is video-only)', async () => {
|
|
339
|
+
onTestFinished(installCanvasHarness())
|
|
340
|
+
const project = makeProject({
|
|
341
|
+
audio: { tracks: [{ id: 'a0', src: 'vo.wav', start: 0, end: 4 }] },
|
|
342
|
+
} as unknown as Partial<Project>)
|
|
343
|
+
const renderGenerationPanel = vi.fn(() => <div data-testid="generation-panel">Regenerate</div>)
|
|
344
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project), { renderGenerationPanel })
|
|
345
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
346
|
+
|
|
347
|
+
selectCanvasItem(container, project, { id: 'a0' })
|
|
348
|
+
|
|
349
|
+
expect(await screen.findByLabelText('Track label')).toBeTruthy()
|
|
350
|
+
expect(screen.queryByTestId('generation-panel')).toBeNull()
|
|
351
|
+
expect(renderGenerationPanel).not.toHaveBeenCalled()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// ── Task H: the drag-preview feedback loop ─────────────────────────────────
|
|
355
|
+
it('a speed drag round-trips: the previewed value survives to pointerup and lands in the saved project', async () => {
|
|
356
|
+
onTestFinished(installCanvasHarness())
|
|
357
|
+
const project = makeProject()
|
|
358
|
+
const adapter = makeFakeAdapter(project)
|
|
359
|
+
const { container } = renderCapCut(project, adapter)
|
|
360
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
361
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
362
|
+
fireEvent.click(await clipTab('Speed'))
|
|
363
|
+
|
|
364
|
+
const slider = await screen.findByRole('slider', { name: 'Speed' })
|
|
365
|
+
fireEvent.change(slider, { target: { value: '2' } })
|
|
366
|
+
|
|
367
|
+
// The load-bearing assertion. The panel is controlled off `selection`,
|
|
368
|
+
// which is derived from the project — so the preview must have updated the
|
|
369
|
+
// project state, or React would have reverted the input to 1 by now and the
|
|
370
|
+
// commit below would read the pre-drag value off the DOM.
|
|
371
|
+
expect(slider).toHaveValue('2')
|
|
372
|
+
|
|
373
|
+
fireEvent.pointerUp(slider)
|
|
374
|
+
|
|
375
|
+
await waitFor(() => {
|
|
376
|
+
const items = lastSaved(adapter).tracks?.[0]?.items ?? []
|
|
377
|
+
// `end` re-fit through setClipSpeed: the same 10s source window at 2×.
|
|
378
|
+
expect(items[0]).toMatchObject({ id: 'clip-0', speed: 2, end: 5 })
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
// ── Task G: ripple after a committed speed change ──────────────────────────
|
|
383
|
+
it('with the magnet on, a committed speed change closes the gap it opened', async () => {
|
|
384
|
+
onTestFinished(installCanvasHarness())
|
|
385
|
+
const project = makeProject()
|
|
386
|
+
const adapter = makeFakeAdapter(project)
|
|
387
|
+
const { container } = renderCapCut(project, adapter)
|
|
388
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
389
|
+
|
|
390
|
+
// Ripple on FIRST — the fixture has no gaps, so enabling it is a no-op and
|
|
391
|
+
// the only gap in play is the one the speed change opens below.
|
|
392
|
+
fireEvent.click(screen.getByLabelText('Ripple mode'))
|
|
393
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
394
|
+
fireEvent.click(await clipTab('Speed'))
|
|
395
|
+
|
|
396
|
+
const slider = await screen.findByRole('slider', { name: 'Speed' })
|
|
397
|
+
fireEvent.change(slider, { target: { value: '2' } })
|
|
398
|
+
fireEvent.pointerUp(slider)
|
|
399
|
+
|
|
400
|
+
await waitFor(() => {
|
|
401
|
+
const items = lastSaved(adapter).tracks?.[0]?.items ?? []
|
|
402
|
+
expect(items[0]).toMatchObject({ id: 'clip-0', speed: 2, end: 5 })
|
|
403
|
+
// The sibling followed the shrink — collapseGaps ran on the same commit.
|
|
404
|
+
expect(items[1]).toMatchObject({ id: 'clip-1', start: 5, end: 9 })
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('with the magnet off, the same speed change leaves the gap open', async () => {
|
|
409
|
+
onTestFinished(installCanvasHarness())
|
|
410
|
+
const project = makeProject()
|
|
411
|
+
const adapter = makeFakeAdapter(project)
|
|
412
|
+
const { container } = renderCapCut(project, adapter)
|
|
413
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
414
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
415
|
+
fireEvent.click(await clipTab('Speed'))
|
|
416
|
+
|
|
417
|
+
const slider = await screen.findByRole('slider', { name: 'Speed' })
|
|
418
|
+
fireEvent.change(slider, { target: { value: '2' } })
|
|
419
|
+
fireEvent.pointerUp(slider)
|
|
420
|
+
|
|
421
|
+
await waitFor(() => {
|
|
422
|
+
const items = lastSaved(adapter).tracks?.[0]?.items ?? []
|
|
423
|
+
expect(items[0]).toMatchObject({ id: 'clip-0', speed: 2, end: 5 })
|
|
424
|
+
expect(items[1]).toMatchObject({ id: 'clip-1', start: 10, end: 14 })
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// NOTE the name: this does NOT exercise the magnet, and cannot — see the
|
|
429
|
+
// comment below. It pins the OTHER half of the gate: that the ripple keys on
|
|
430
|
+
// a DURATION change and not on "any commit". The magnet-on/magnet-off pair
|
|
431
|
+
// above is what proves `rippleMode` itself gates the collapse.
|
|
432
|
+
it('a mute toggle does not ripple, because the ripple keys on a duration change', async () => {
|
|
433
|
+
onTestFinished(installCanvasHarness())
|
|
434
|
+
const project = makeProject({
|
|
435
|
+
// A REAL gap this time, so a stray collapseGaps would be unmissable.
|
|
436
|
+
tracks: [[
|
|
437
|
+
{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110 },
|
|
438
|
+
{ id: 'clip-1', type: 'video', src: 'b.mp4', start: 20, end: 24, inPoint: 0, outPoint: 4 },
|
|
439
|
+
]],
|
|
440
|
+
} as unknown as Partial<Project>)
|
|
441
|
+
const adapter = makeFakeAdapter(project)
|
|
442
|
+
const { container } = renderCapCut(project, adapter)
|
|
443
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
444
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
445
|
+
fireEvent.click(await clipTab('Volume'))
|
|
446
|
+
|
|
447
|
+
// Deliberately NOT touching the magnet: `handleRippleToggle` collapses on
|
|
448
|
+
// enable, which would close this gap before the mute even happened.
|
|
449
|
+
fireEvent.click(await screen.findByLabelText('Mute clip'))
|
|
450
|
+
|
|
451
|
+
await waitFor(() => {
|
|
452
|
+
const items = lastSaved(adapter).tracks?.[0]?.items ?? []
|
|
453
|
+
expect(items[0]).toMatchObject({ id: 'clip-0', muted: true })
|
|
454
|
+
expect(items[1]).toMatchObject({ id: 'clip-1', start: 20 })
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
// ── Tab set varies with the selection ───────────────────────────────────
|
|
459
|
+
it('offers only Transform and Volume for a selected image clip', async () => {
|
|
460
|
+
onTestFinished(installCanvasHarness())
|
|
461
|
+
const project = makeProject({
|
|
462
|
+
tracks: [[{ id: 'img-0', type: 'image', src: 'photo.jpg', start: 0, end: 4 }]],
|
|
463
|
+
} as unknown as Partial<Project>)
|
|
464
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
465
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
466
|
+
|
|
467
|
+
selectCanvasItem(container, project, { type: 'image' })
|
|
468
|
+
|
|
469
|
+
const strip = await screen.findByRole('group', { name: 'Clip panel view' })
|
|
470
|
+
// No Speed (image, not video), no Crop (not a tracks[0] video), no
|
|
471
|
+
// Generate (generation is video-only).
|
|
472
|
+
expect(clipTabNames(strip)).toEqual(['Transform', 'Volume'])
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('offers no Generate tab for an ordinary video clip with no generation provenance', async () => {
|
|
476
|
+
onTestFinished(installCanvasHarness())
|
|
477
|
+
// The default fixture: a plain video clip, no `generation` field, and
|
|
478
|
+
// `renderCapCut` here passes neither `regenEnabled` nor
|
|
479
|
+
// `renderGenerationPanel`. This is the shipping shape of every
|
|
480
|
+
// non-ai_video project — the Generate tab must not appear for it, or
|
|
481
|
+
// every ordinary video clip in every ordinary project grows a dead,
|
|
482
|
+
// empty tab.
|
|
483
|
+
const project = makeProject()
|
|
484
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
485
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
486
|
+
|
|
487
|
+
// clip-0 is the tracks[0] video with a src — also the crop target, so
|
|
488
|
+
// Crop is expected here alongside Transform/Speed/Volume.
|
|
489
|
+
selectCanvasItem(container, project, { id: 'clip-0' })
|
|
490
|
+
|
|
491
|
+
const strip = await screen.findByRole('group', { name: 'Clip panel view' })
|
|
492
|
+
expect(clipTabNames(strip)).toEqual(['Transform', 'Speed', 'Volume', 'Crop'])
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('offers no Crop tab for a video clip that is not the crop target', async () => {
|
|
496
|
+
onTestFinished(installCanvasHarness())
|
|
497
|
+
// clip-1 is a VIDEO, but on the second track — `cropTarget` only ever
|
|
498
|
+
// matches tracks[0], so it still gets Speed/Generate, just no Crop. It
|
|
499
|
+
// also carries `generation` provenance and regen is enabled, so Generate
|
|
500
|
+
// stays in the expected set here rather than being the thing under test.
|
|
501
|
+
const project = makeProject({
|
|
502
|
+
tracks: [
|
|
503
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110 }],
|
|
504
|
+
[{ id: 'clip-1', type: 'video', src: 'b.mp4', start: 0, end: 6, inPoint: 0, outPoint: 6,
|
|
505
|
+
generation: { sceneId: 'sc-1', prompt: 'a wide shot', provider: 'kling' } }],
|
|
506
|
+
],
|
|
507
|
+
} as unknown as Partial<Project>)
|
|
508
|
+
const renderGenerationPanel = vi.fn(() => <div data-testid="generation-panel">Regenerate</div>)
|
|
509
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project), { regenEnabled: true, renderGenerationPanel })
|
|
510
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
511
|
+
|
|
512
|
+
selectCanvasItem(container, project, { id: 'clip-1' })
|
|
513
|
+
|
|
514
|
+
const strip = await screen.findByRole('group', { name: 'Clip panel view' })
|
|
515
|
+
expect(clipTabNames(strip)).toEqual(['Transform', 'Speed', 'Volume', 'Generate'])
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it("clicking the Crop tab's button enters crop mode and stays entered on a second click (unlike the toolbar's toggle)", async () => {
|
|
519
|
+
onTestFinished(installCanvasHarness())
|
|
520
|
+
const project = makeProject()
|
|
521
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
522
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
523
|
+
// clip-0 is the tracks[0] video with a src — the crop target.
|
|
524
|
+
selectCanvasItem(container, project, { id: 'clip-0' })
|
|
525
|
+
|
|
526
|
+
fireEvent.click(await clipTab('Crop'))
|
|
527
|
+
fireEvent.click(await cropTabBodyButton())
|
|
528
|
+
|
|
529
|
+
// Same underlying `cropMode` state the toolbar's own "Crop source" button
|
|
530
|
+
// drives — its aria-pressed flips too, proving there is only one crop path.
|
|
531
|
+
expect(screen.getByLabelText('Crop source').getAttribute('aria-pressed')).toBe('true')
|
|
532
|
+
|
|
533
|
+
// A second click must NOT exit crop mode: the tab body button is a
|
|
534
|
+
// one-way enter, not a toggle like the toolbar button it mirrors.
|
|
535
|
+
fireEvent.click(await cropTabBodyButton())
|
|
536
|
+
expect(screen.getByLabelText('Crop source').getAttribute('aria-pressed')).toBe('true')
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
// ── The CapCut left panel ────────────────────────────────────────────────────
|
|
541
|
+
// Media, Captions and Versions now live behind an icon rail on the LEFT, where
|
|
542
|
+
// captions and version history used to stack into the right rail. Tabs are lazy:
|
|
543
|
+
// only the active one is mounted, so "the media panel is on screen" is now
|
|
544
|
+
// "the Media tab is selected", not "the editor is in the CapCut layout".
|
|
545
|
+
describe('VideoEditor — CapCut left panel tabs', () => {
|
|
546
|
+
function makeCaptionedProject(): Project {
|
|
547
|
+
return makeProject({
|
|
548
|
+
captions: { style: 'pop', segments: [{ id: 'cap-0', text: 'caption one', start: 0, end: 1 }] },
|
|
549
|
+
} as unknown as Partial<Project>)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
it('opens on Captions and keeps Media behind its own tab', async () => {
|
|
553
|
+
const project = makeCaptionedProject()
|
|
554
|
+
renderCapCut(project, makeFakeAdapter(project))
|
|
555
|
+
|
|
556
|
+
const captionsTab = await screen.findByRole('tab', { name: 'Captions' })
|
|
557
|
+
expect(captionsTab.getAttribute('aria-selected')).toBe('true')
|
|
558
|
+
expect(await screen.findByText('caption one')).toBeTruthy()
|
|
559
|
+
// Lazy mount: the Media tab's content has never been rendered.
|
|
560
|
+
expect(screen.queryByTestId('media-panel')).toBeNull()
|
|
561
|
+
|
|
562
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Media' }))
|
|
563
|
+
expect(await screen.findByTestId('media-panel')).toBeTruthy()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('puts version history and the host runHistory slot together under Versions', async () => {
|
|
567
|
+
const project = makeCaptionedProject()
|
|
568
|
+
renderCapCut(project, makeFakeAdapter(project), {
|
|
569
|
+
slots: { runHistory: <div data-testid="run-history">Previous runs</div> },
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
fireEvent.click(await screen.findByRole('tab', { name: 'Versions' }))
|
|
573
|
+
expect(await screen.findByTestId('run-history')).toBeTruthy()
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('offers no Captions tab on a project with no captions and a host that cannot generate them', async () => {
|
|
577
|
+
const project = makeProject() // no `captions`, adapter has no generateCaptions
|
|
578
|
+
renderCapCut(project, makeFakeAdapter(project))
|
|
579
|
+
|
|
580
|
+
await waitFor(() => screen.getByRole('tab', { name: 'Media' }))
|
|
581
|
+
expect(screen.queryByRole('tab', { name: 'Captions' })).toBeNull()
|
|
582
|
+
// `defaultTabId="captions"` names a tab that isn't there, so LeftPanelTabs
|
|
583
|
+
// falls back to the first one — Media is open, not a blank pane.
|
|
584
|
+
expect(screen.getByTestId('media-panel')).toBeTruthy()
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
// ── The overlay Content / Transform tabs ─────────────────────────────────────
|
|
589
|
+
//
|
|
590
|
+
// A selected overlay's own props (its text, colors, numbers, toggles, images)
|
|
591
|
+
// used to live in `OverlayPropsModal` — a floating, draggable dialog with a Save
|
|
592
|
+
// button, opened by a preview double-click or a Pencil in the controls bar. That
|
|
593
|
+
// dialog is retired. Those fields are the **Content** tab of this same right
|
|
594
|
+
// column now, and the keyframe inspector is the **Transform** tab beside it.
|
|
595
|
+
// Content is the default: what an overlay SAYS is what you reach for first.
|
|
596
|
+
//
|
|
597
|
+
// Two consequences worth pinning here rather than in OverlayContentPanel's own
|
|
598
|
+
// suite, because they only exist at THIS seam:
|
|
599
|
+
//
|
|
600
|
+
// 1. There is no dialog left to open, so the double-click has nothing to do
|
|
601
|
+
// but leave the already-selected overlay where it is — and nothing may
|
|
602
|
+
// portal itself over the preview.
|
|
603
|
+
// 2. Edits ride VideoEditor's sync core (transient preview → commit on blur),
|
|
604
|
+
// so a typed change has to reach `saveProject` without a Save button
|
|
605
|
+
// anywhere in the loop.
|
|
606
|
+
describe('VideoEditor — overlay properties tabs', () => {
|
|
607
|
+
/** The clip fixture plus a JSX overlay on its own track. */
|
|
608
|
+
function makeOverlayProject(): Project {
|
|
609
|
+
return makeProject({
|
|
610
|
+
tracks: [
|
|
611
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110 }],
|
|
612
|
+
[{
|
|
613
|
+
id: 'ov-0',
|
|
614
|
+
type: 'overlay',
|
|
615
|
+
src: 'scoreboard.jsx',
|
|
616
|
+
start: 0,
|
|
617
|
+
end: 10,
|
|
618
|
+
props: { text: 'Full time', accent: '#FCD116' },
|
|
619
|
+
}],
|
|
620
|
+
],
|
|
621
|
+
} as unknown as Partial<Project>)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* One of the two tab buttons, resolved INSIDE the tab strip.
|
|
626
|
+
*
|
|
627
|
+
* Scoping is defensive, not load-bearing: `OverlayInspector`'s own
|
|
628
|
+
* "Transform" section header is an inert `<span>`, not a button, so there
|
|
629
|
+
* is currently only one "Transform" button in the tree and a bare
|
|
630
|
+
* `getByRole('button', { name: 'Transform' })` would not throw today. But a
|
|
631
|
+
* tab body can grow a control whose accessible name collides with a tab
|
|
632
|
+
* label, so resolving inside the tab strip's `role="group"` keeps this
|
|
633
|
+
* query safe against that regardless.
|
|
634
|
+
*/
|
|
635
|
+
async function overlayTab(name: 'Content' | 'Transform'): Promise<HTMLButtonElement> {
|
|
636
|
+
const strip = await screen.findByRole('group', { name: 'Overlay panel view' })
|
|
637
|
+
const button = Array.from(strip.querySelectorAll('button')).find(b => b.textContent === name)
|
|
638
|
+
if (!button) throw new Error(`no "${name}" tab in the overlay panel strip`)
|
|
639
|
+
return button
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
it('opens on the Content tab, showing the overlay\'s own props', async () => {
|
|
643
|
+
onTestFinished(installCanvasHarness())
|
|
644
|
+
const project = makeOverlayProject()
|
|
645
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
646
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
647
|
+
|
|
648
|
+
selectCanvasItem(container, project, { type: 'overlay' })
|
|
649
|
+
|
|
650
|
+
// Content is the default with nothing persisted.
|
|
651
|
+
expect((await overlayTab('Content')).getAttribute('aria-pressed')).toBe('true')
|
|
652
|
+
expect((await overlayTab('Transform')).getAttribute('aria-pressed')).toBe('false')
|
|
653
|
+
// The overlay's OWN props, inferred — a text field and a color swatch.
|
|
654
|
+
expect(screen.getByLabelText('text')).toBeTruthy()
|
|
655
|
+
expect((screen.getByLabelText('accent') as HTMLInputElement).type).toBe('color')
|
|
656
|
+
// Exactly one tab body renders: the Transform inspector is not stacked under it.
|
|
657
|
+
expect(screen.queryByLabelText('Scale')).toBeNull()
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('switching to Transform swaps in the keyframe inspector', async () => {
|
|
661
|
+
onTestFinished(installCanvasHarness())
|
|
662
|
+
const project = makeOverlayProject()
|
|
663
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
664
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
665
|
+
selectCanvasItem(container, project, { type: 'overlay' })
|
|
666
|
+
|
|
667
|
+
fireEvent.click(await overlayTab('Transform'))
|
|
668
|
+
|
|
669
|
+
expect(screen.getByLabelText('Scale')).toBeTruthy()
|
|
670
|
+
expect(screen.getByLabelText('Offset X')).toBeTruthy()
|
|
671
|
+
// ...and the Content body is gone, not hidden behind it.
|
|
672
|
+
expect(screen.queryByLabelText('text')).toBeNull()
|
|
673
|
+
// The choice is written through to storage, not just held in React state.
|
|
674
|
+
expect(localStorage.getItem('montaj.editor.overlayPanelTab')).toBe(JSON.stringify('transform'))
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('opens on the tab the operator last used', async () => {
|
|
678
|
+
onTestFinished(installCanvasHarness())
|
|
679
|
+
// The write half is asserted above; this is the READ half, and it needs a
|
|
680
|
+
// fresh mount to mean anything — usePersistentState consults storage once,
|
|
681
|
+
// in its lazy `useState` initializer, and never again.
|
|
682
|
+
localStorage.setItem('montaj.editor.overlayPanelTab', JSON.stringify('transform'))
|
|
683
|
+
const project = makeOverlayProject()
|
|
684
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
685
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
686
|
+
selectCanvasItem(container, project, { type: 'overlay' })
|
|
687
|
+
|
|
688
|
+
expect((await overlayTab('Transform')).getAttribute('aria-pressed')).toBe('true')
|
|
689
|
+
expect(screen.getByLabelText('Scale')).toBeTruthy()
|
|
690
|
+
expect(screen.queryByLabelText('text')).toBeNull()
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it('falls back to Content when the stored tab name is one this build does not know', async () => {
|
|
694
|
+
onTestFinished(installCanvasHarness())
|
|
695
|
+
localStorage.setItem('montaj.editor.overlayPanelTab', JSON.stringify('effects'))
|
|
696
|
+
const project = makeOverlayProject()
|
|
697
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
698
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
699
|
+
selectCanvasItem(container, project, { type: 'overlay' })
|
|
700
|
+
|
|
701
|
+
// A stale key from an older build must not render a blank pane.
|
|
702
|
+
expect((await overlayTab('Content')).getAttribute('aria-pressed')).toBe('true')
|
|
703
|
+
expect(screen.getByLabelText('text')).toBeTruthy()
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
it('a Content edit previews live and lands in the saved project on blur — no Save button in the loop', async () => {
|
|
707
|
+
onTestFinished(installCanvasHarness())
|
|
708
|
+
const project = makeOverlayProject()
|
|
709
|
+
const adapter = makeFakeAdapter(project)
|
|
710
|
+
const { container } = renderCapCut(project, adapter)
|
|
711
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
712
|
+
selectCanvasItem(container, project, { type: 'overlay' })
|
|
713
|
+
|
|
714
|
+
const field = await screen.findByLabelText('text')
|
|
715
|
+
fireEvent.change(field, { target: { value: 'Half time' } })
|
|
716
|
+
// The field is controlled off the project, so it only reads back what was
|
|
717
|
+
// typed if the transient preview actually reached the sync core.
|
|
718
|
+
expect(field).toHaveValue('Half time')
|
|
719
|
+
// The dialog this replaces persisted on a Save click; a panel has no such
|
|
720
|
+
// moment, so nothing is saved until the typing gesture closes.
|
|
721
|
+
expect(screen.queryByRole('button', { name: /save/i })).toBeNull()
|
|
722
|
+
|
|
723
|
+
fireEvent.blur(field)
|
|
724
|
+
|
|
725
|
+
await waitFor(() => {
|
|
726
|
+
const overlay = lastSaved(adapter).tracks?.[1]?.items?.[0] as { props?: Record<string, unknown> }
|
|
727
|
+
// The untouched sibling prop rides through the write untouched.
|
|
728
|
+
expect(overlay.props).toEqual({ text: 'Half time', accent: '#FCD116' })
|
|
729
|
+
})
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('a preview double-click keeps the overlay on the Content tab and opens no dialog', async () => {
|
|
733
|
+
onTestFinished(installCanvasHarness())
|
|
734
|
+
const project = makeOverlayProject()
|
|
735
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
736
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
737
|
+
selectCanvasItem(container, project, { type: 'overlay' })
|
|
738
|
+
await screen.findByLabelText('text')
|
|
739
|
+
|
|
740
|
+
// The preview's move surface, inside the selected overlay's wrapper — the
|
|
741
|
+
// wrapper is what carries `onDoubleClick` (OverlayItemsLayer), and the
|
|
742
|
+
// handler is gated on the overlay ALREADY being selected, so this path can
|
|
743
|
+
// only ever re-select. It used to portal a dialog over the preview.
|
|
744
|
+
fireEvent.dblClick(container.querySelector('.cursor-grab') as HTMLElement)
|
|
745
|
+
|
|
746
|
+
expect(screen.getByLabelText('text')).toBeTruthy()
|
|
747
|
+
expect((await overlayTab('Content')).getAttribute('aria-pressed')).toBe('true')
|
|
748
|
+
// Nothing portalled itself over the preview: no dialog header, no Save.
|
|
749
|
+
expect(screen.queryByRole('heading', { name: 'Edit overlay' })).toBeNull()
|
|
750
|
+
expect(screen.queryByRole('button', { name: /save/i })).toBeNull()
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
it('no longer offers an "Edit overlay" button in the controls bar', async () => {
|
|
754
|
+
onTestFinished(installCanvasHarness())
|
|
755
|
+
const project = makeOverlayProject()
|
|
756
|
+
const { container } = renderCapCut(project, makeFakeAdapter(project))
|
|
757
|
+
await waitFor(() => screen.getByLabelText('Resize sidebar'))
|
|
758
|
+
selectCanvasItem(container, project, { type: 'overlay' })
|
|
759
|
+
|
|
760
|
+
// Selecting the overlay IS opening its properties now, so the button whose
|
|
761
|
+
// only job was to open them has nothing left to do.
|
|
762
|
+
await screen.findByLabelText('text')
|
|
763
|
+
expect(screen.queryByLabelText('Edit overlay')).toBeNull()
|
|
764
|
+
})
|
|
765
|
+
})
|