@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,241 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act, fireEvent } from '@testing-library/react'
|
|
3
|
+
import type {
|
|
4
|
+
AnalyzeAudioPolishArgs,
|
|
5
|
+
AudioPolishAnalysis,
|
|
6
|
+
EditorAdapter,
|
|
7
|
+
ImageElement,
|
|
8
|
+
Project,
|
|
9
|
+
RenderEvent,
|
|
10
|
+
VersionEntry,
|
|
11
|
+
WaveformChunk,
|
|
12
|
+
} from '../../types'
|
|
13
|
+
import VideoEditor from '../VideoEditor'
|
|
14
|
+
import { trackItems } from '../timeline/timeline-model'
|
|
15
|
+
|
|
16
|
+
// ── Fake adapter ──────────────────────────────────────────────────────────────
|
|
17
|
+
// Trimmed down from VideoEditor.test.tsx's — this suite only needs the pieces
|
|
18
|
+
// VideoEditor threads unconditionally plus `analyzeAudioPolish`, which each
|
|
19
|
+
// test adds itself so "adapter omits it" is a real absence, not a stubbed-out
|
|
20
|
+
// no-op.
|
|
21
|
+
|
|
22
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
23
|
+
return {
|
|
24
|
+
id: 'vid-1',
|
|
25
|
+
name: 'Test Video',
|
|
26
|
+
status: 'draft',
|
|
27
|
+
editingPrompt: '',
|
|
28
|
+
projectType: 'video',
|
|
29
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
30
|
+
tracks: [
|
|
31
|
+
[
|
|
32
|
+
{
|
|
33
|
+
id: 'clip-0',
|
|
34
|
+
type: 'video',
|
|
35
|
+
src: 'a.mp4',
|
|
36
|
+
start: 0,
|
|
37
|
+
end: 4,
|
|
38
|
+
inPoint: 0,
|
|
39
|
+
outPoint: 4,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
],
|
|
43
|
+
audio: { tracks: [] },
|
|
44
|
+
assets: [],
|
|
45
|
+
...overrides,
|
|
46
|
+
} as Project
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface FakeAdapter extends EditorAdapter<Project> {
|
|
50
|
+
saveCalls: Array<{ id: string; project: Project }>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeFakeAdapter(): FakeAdapter {
|
|
54
|
+
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
55
|
+
return {
|
|
56
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
57
|
+
saveProject: vi.fn(async (id: string, project: Project) => {
|
|
58
|
+
saveCalls.push({ id, project })
|
|
59
|
+
}),
|
|
60
|
+
subscribe: () => () => {},
|
|
61
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
62
|
+
yield { type: 'done', outputPath: '/out.mp4' }
|
|
63
|
+
},
|
|
64
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
65
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
66
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
67
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
68
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
69
|
+
fileUrl: (path: string) => path,
|
|
70
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
71
|
+
restoreVersion: vi.fn(async (_id: string, _hash: string) => makeVideoProject()),
|
|
72
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
73
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
74
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
75
|
+
saveCalls,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A loudness-only analyze mock: returns a fixed measurement that yields a
|
|
80
|
+
* nonzero, sub-ceiling gain (see audioPolish.ts's loudnessGainDb) so Apply
|
|
81
|
+
* produces a real, observable change without needing caption/removal
|
|
82
|
+
* fixtures the wiring under test does not care about. */
|
|
83
|
+
function loudnessOnlyAnalyze(): (args: AnalyzeAudioPolishArgs) => Promise<AudioPolishAnalysis> {
|
|
84
|
+
return async (args) => {
|
|
85
|
+
if (args.piece !== 'loudness') throw new Error(`unexpected piece requested: ${args.piece}`)
|
|
86
|
+
return { piece: 'loudness', measuredI: -20, measuredTP: -20, measuredLRA: 5, targetI: -14, gainDb: 6 }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
92
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
93
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
94
|
+
observe() {}
|
|
95
|
+
unobserve() {}
|
|
96
|
+
disconnect() {}
|
|
97
|
+
}
|
|
98
|
+
// jsdom doesn't implement media element playback.
|
|
99
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
100
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
101
|
+
// jsdom has no Web Audio API; the video player wires per-clip gain through it.
|
|
102
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
103
|
+
state = 'running'
|
|
104
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
105
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
106
|
+
get destination() { return {} }
|
|
107
|
+
close() {}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
afterEach(() => vi.restoreAllMocks())
|
|
111
|
+
|
|
112
|
+
describe('VideoEditor — audio polish wiring', () => {
|
|
113
|
+
it('shows neither the toolbar button nor the palette entry when the adapter omits analyzeAudioPolish', async () => {
|
|
114
|
+
const adapter = makeFakeAdapter() // no analyzeAudioPolish
|
|
115
|
+
const { queryByRole, findByPlaceholderText, queryByText } = render(
|
|
116
|
+
<VideoEditor
|
|
117
|
+
project={makeVideoProject()}
|
|
118
|
+
adapter={adapter}
|
|
119
|
+
onProjectChange={vi.fn()}
|
|
120
|
+
slots={{ exportActions: <div /> }}
|
|
121
|
+
/>,
|
|
122
|
+
)
|
|
123
|
+
await waitFor(() => expect(document.querySelector('video')).not.toBeNull())
|
|
124
|
+
expect(queryByRole('button', { name: 'Polish audio' })).toBeNull()
|
|
125
|
+
|
|
126
|
+
await act(async () => {
|
|
127
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
|
128
|
+
})
|
|
129
|
+
// Palette open: the filtered command list is present (its search input's
|
|
130
|
+
// placeholder, not text content — CommandPalette.tsx).
|
|
131
|
+
await findByPlaceholderText('Type a command…')
|
|
132
|
+
expect(queryByText('Polish audio…')).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('shows the toolbar button and opens the modal when the adapter provides analyzeAudioPolish', async () => {
|
|
136
|
+
const adapter = makeFakeAdapter()
|
|
137
|
+
adapter.analyzeAudioPolish = vi.fn(loudnessOnlyAnalyze())
|
|
138
|
+
const { findByRole } = render(
|
|
139
|
+
<VideoEditor
|
|
140
|
+
project={makeVideoProject()}
|
|
141
|
+
adapter={adapter}
|
|
142
|
+
onProjectChange={vi.fn()}
|
|
143
|
+
slots={{ exportActions: <div /> }}
|
|
144
|
+
/>,
|
|
145
|
+
)
|
|
146
|
+
const button = await findByRole('button', { name: 'Polish audio' })
|
|
147
|
+
fireEvent.click(button)
|
|
148
|
+
await findByRole('dialog', { name: 'Polish audio' })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Real useProjectSync — deliberately not mocked. A component test with a
|
|
152
|
+
// mocked sync could only assert that VideoEditor CALLS mutateTransient/
|
|
153
|
+
// commit/discardTransient, not that the real undo stack ends up with the
|
|
154
|
+
// right number of entries; that requires the genuine implementation.
|
|
155
|
+
it('Apply lands the whole polish as exactly one undo entry', async () => {
|
|
156
|
+
const adapter = makeFakeAdapter()
|
|
157
|
+
adapter.analyzeAudioPolish = vi.fn(loudnessOnlyAnalyze())
|
|
158
|
+
const { findByRole, getByLabelText, getByText, queryByRole } = render(
|
|
159
|
+
<VideoEditor
|
|
160
|
+
project={makeVideoProject()}
|
|
161
|
+
adapter={adapter}
|
|
162
|
+
onProjectChange={vi.fn()}
|
|
163
|
+
slots={{ exportActions: <div /> }}
|
|
164
|
+
/>,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
fireEvent.click(await findByRole('button', { name: 'Polish audio' }))
|
|
168
|
+
await findByRole('dialog', { name: 'Polish audio' })
|
|
169
|
+
|
|
170
|
+
// Loudness only: unticking silence/fillers keeps this test to the wiring
|
|
171
|
+
// under test rather than the planner's removal/guard logic, which
|
|
172
|
+
// AudioPolishModal.test.tsx already covers on its own.
|
|
173
|
+
fireEvent.click(getByLabelText('Remove silence'))
|
|
174
|
+
fireEvent.click(getByLabelText('Remove filler words'))
|
|
175
|
+
fireEvent.click(getByText('Analyse'))
|
|
176
|
+
|
|
177
|
+
await waitFor(() => expect(getByText('Apply')).not.toBeDisabled())
|
|
178
|
+
expect(adapter.analyzeAudioPolish).toHaveBeenCalledTimes(1)
|
|
179
|
+
|
|
180
|
+
const savesBeforeApply = adapter.saveCalls.length
|
|
181
|
+
fireEvent.click(getByText('Apply'))
|
|
182
|
+
await waitFor(() => expect(queryByRole('dialog', { name: 'Polish audio' })).toBeNull())
|
|
183
|
+
|
|
184
|
+
// One commit, one save, and the applied gain actually landed.
|
|
185
|
+
await waitFor(() => expect(adapter.saveCalls.length).toBe(savesBeforeApply + 1))
|
|
186
|
+
const applied = adapter.saveCalls[adapter.saveCalls.length - 1].project
|
|
187
|
+
expect(trackItems(applied)[0][0].volume).toBeGreaterThan(1)
|
|
188
|
+
|
|
189
|
+
// ONE undo reverts the whole polish, whatever the number of toggle/
|
|
190
|
+
// checkbox changes the operator made while reviewing it...
|
|
191
|
+
await act(async () => {
|
|
192
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
193
|
+
})
|
|
194
|
+
await waitFor(() => {
|
|
195
|
+
const reverted = adapter.saveCalls[adapter.saveCalls.length - 1].project
|
|
196
|
+
expect(trackItems(reverted)[0][0].volume).toBeUndefined()
|
|
197
|
+
})
|
|
198
|
+
const savesAfterUndo = adapter.saveCalls.length
|
|
199
|
+
|
|
200
|
+
// ...and a second undo is a no-op: the stack had exactly one entry for
|
|
201
|
+
// the whole gesture, not one per preview rebuild.
|
|
202
|
+
await act(async () => {
|
|
203
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
204
|
+
})
|
|
205
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
206
|
+
expect(adapter.saveCalls.length).toBe(savesAfterUndo)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('Cancel produces no undo entry', async () => {
|
|
210
|
+
const adapter = makeFakeAdapter()
|
|
211
|
+
adapter.analyzeAudioPolish = vi.fn(loudnessOnlyAnalyze())
|
|
212
|
+
const { findByRole, getByLabelText, getByText, queryByRole } = render(
|
|
213
|
+
<VideoEditor
|
|
214
|
+
project={makeVideoProject()}
|
|
215
|
+
adapter={adapter}
|
|
216
|
+
onProjectChange={vi.fn()}
|
|
217
|
+
slots={{ exportActions: <div /> }}
|
|
218
|
+
/>,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
fireEvent.click(await findByRole('button', { name: 'Polish audio' }))
|
|
222
|
+
await findByRole('dialog', { name: 'Polish audio' })
|
|
223
|
+
fireEvent.click(getByLabelText('Remove silence'))
|
|
224
|
+
fireEvent.click(getByLabelText('Remove filler words'))
|
|
225
|
+
fireEvent.click(getByText('Analyse'))
|
|
226
|
+
await waitFor(() => expect(getByText('Apply')).not.toBeDisabled())
|
|
227
|
+
|
|
228
|
+
const savesBeforeCancel = adapter.saveCalls.length
|
|
229
|
+
fireEvent.click(getByText('Cancel'))
|
|
230
|
+
await waitFor(() => expect(queryByRole('dialog', { name: 'Polish audio' })).toBeNull())
|
|
231
|
+
// No commit — nothing queued for save.
|
|
232
|
+
expect(adapter.saveCalls.length).toBe(savesBeforeCancel)
|
|
233
|
+
|
|
234
|
+
// No undo entry either: Cmd+Z is a no-op.
|
|
235
|
+
await act(async () => {
|
|
236
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
237
|
+
})
|
|
238
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
239
|
+
expect(adapter.saveCalls.length).toBe(savesBeforeCancel)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act, fireEvent } from '@testing-library/react'
|
|
3
|
+
import type { EditorAdapter, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
4
|
+
import type { ImageElement } from '../../types'
|
|
5
|
+
import VideoEditor from '../VideoEditor'
|
|
6
|
+
|
|
7
|
+
// ── FIX 2 regression ──────────────────────────────────────────────────────────
|
|
8
|
+
// `handleCaptionSegmentDelete` (VideoEditor.tsx) is the sidebar trash button's
|
|
9
|
+
// commit path. Timeline.tsx's own Delete keymap normalizes caption lanes after
|
|
10
|
+
// an explicit delete (see Timeline.keymap.test.tsx's "Delete collapses and
|
|
11
|
+
// renumbers a caption row it empties"); this handler used to filter the
|
|
12
|
+
// segment out and stop, so deleting the last caption in a row from the
|
|
13
|
+
// SIDEBAR (as opposed to the canvas timeline) persisted a sparse/hole lane to
|
|
14
|
+
// disk — two affordances for the same delete, two different outcomes. This
|
|
15
|
+
// mounts the real editor (not a mocked Timeline: the sidebar delete button
|
|
16
|
+
// doesn't route through Timeline at all) and drives the actual trash button.
|
|
17
|
+
//
|
|
18
|
+
// IMPORTANT: VideoEditor's own general-purpose lane-normalization effect
|
|
19
|
+
// (mount/SSE/regen/etc, keyed on `sync.project.captions`) ALSO catches and
|
|
20
|
+
// closes a hole lane shortly after ANY caption change that isn't a caption
|
|
21
|
+
// drag gesture — via `sync.applyExternal`. So asserting on the LAST
|
|
22
|
+
// `onProjectChange` call (client-side state) passes with or without this fix
|
|
23
|
+
// and proves nothing: `applyExternal` never calls `adapter.saveProject`, only
|
|
24
|
+
// `sync.mutate`/`commit`/`undo`/`redo` do. The actual bug is what gets
|
|
25
|
+
// PERSISTED — what a reload or another client would see — which is exactly
|
|
26
|
+
// what `handleCaptionSegmentDelete`'s own `syncMutate` call hands to the
|
|
27
|
+
// adapter. So this test asserts on the adapter's `saveProject` payload.
|
|
28
|
+
// (Verified against a scratchpad revert: without the fix, asserting on
|
|
29
|
+
// `onProjectChange` alone passes regardless of the fix — asserting on the
|
|
30
|
+
// saved payload correctly fails without it, with cap-2 stuck at lane 2.)
|
|
31
|
+
|
|
32
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
33
|
+
return {
|
|
34
|
+
id: 'vid-1',
|
|
35
|
+
name: 'Test Video',
|
|
36
|
+
status: 'draft',
|
|
37
|
+
editingPrompt: '',
|
|
38
|
+
projectType: 'video',
|
|
39
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
40
|
+
tracks: [
|
|
41
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
42
|
+
],
|
|
43
|
+
audio: { tracks: [] },
|
|
44
|
+
assets: [],
|
|
45
|
+
...overrides,
|
|
46
|
+
} as Project
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface FakeAdapter extends EditorAdapter<Project> {
|
|
50
|
+
saveCalls: Array<{ id: string; project: Project }>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeFakeAdapter(): FakeAdapter {
|
|
54
|
+
let subscribers: Array<(project: Project) => void> = []
|
|
55
|
+
const saveCalls: Array<{ id: string; project: Project }> = []
|
|
56
|
+
return {
|
|
57
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
58
|
+
saveProject: vi.fn(async (id: string, project: Project) => { saveCalls.push({ id, project }) }),
|
|
59
|
+
subscribe: (_id: string, onFrame: (project: Project) => void) => {
|
|
60
|
+
subscribers.push(onFrame)
|
|
61
|
+
return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
|
|
62
|
+
},
|
|
63
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
64
|
+
yield { type: 'done', outputPath: '/out.mp4' }
|
|
65
|
+
},
|
|
66
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
67
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
68
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
69
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
70
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
71
|
+
fileUrl: (path: string) => path,
|
|
72
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
73
|
+
restoreVersion: vi.fn(async () => makeVideoProject()),
|
|
74
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
75
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
76
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
77
|
+
saveCalls,
|
|
78
|
+
} as unknown as FakeAdapter
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
// The caption panel now splits into Style / Captions sub-tabs defaulting to
|
|
83
|
+
// Style; this test deletes from the transcript list, so pin the sub-tab to
|
|
84
|
+
// 'captions' (usePersistentState reads this at mount).
|
|
85
|
+
window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
|
|
86
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
87
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
88
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
89
|
+
observe() {}
|
|
90
|
+
unobserve() {}
|
|
91
|
+
disconnect() {}
|
|
92
|
+
}
|
|
93
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
94
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
95
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
96
|
+
state = 'running'
|
|
97
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
98
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
99
|
+
get destination() { return {} }
|
|
100
|
+
close() {}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
afterEach(() => vi.restoreAllMocks())
|
|
104
|
+
|
|
105
|
+
describe('VideoEditor — sidebar caption delete densifies lanes (FIX 2)', () => {
|
|
106
|
+
it('removing the only caption in a row from the sidebar trash button PERSISTS a collapsed, dense row', async () => {
|
|
107
|
+
const adapter = makeFakeAdapter()
|
|
108
|
+
const initial = makeVideoProject({
|
|
109
|
+
captions: {
|
|
110
|
+
style: 'clean',
|
|
111
|
+
segments: [
|
|
112
|
+
{ id: 'cap-0', text: 'ground', start: 0, end: 1, words: [{ word: 'ground', start: 0, end: 1 }] },
|
|
113
|
+
{ id: 'cap-1', text: 'middle', start: 1, end: 2, lane: 1, words: [{ word: 'middle', start: 1, end: 2 }] },
|
|
114
|
+
{ id: 'cap-2', text: 'top', start: 2, end: 3, lane: 2, words: [{ word: 'top', start: 2, end: 3 }] },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
} as Partial<Project>)
|
|
118
|
+
const { getByText } = render(
|
|
119
|
+
<VideoEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} />,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Locate the sole row-1 segment's own delete button — not by array index
|
|
123
|
+
// (row order in the sidebar isn't the point under test here), by scoping
|
|
124
|
+
// to its listitem.
|
|
125
|
+
const middleRow = (await waitFor(() => getByText('middle'))).closest('[role="listitem"]') as HTMLElement
|
|
126
|
+
const deleteBtn = middleRow.querySelector('button[aria-label="Delete caption"]') as HTMLButtonElement
|
|
127
|
+
expect(deleteBtn).toBeTruthy()
|
|
128
|
+
|
|
129
|
+
await act(async () => { fireEvent.click(deleteBtn) })
|
|
130
|
+
|
|
131
|
+
await waitFor(() => expect(adapter.saveCalls.length).toBeGreaterThan(0))
|
|
132
|
+
// Give any redundant client-side patch-up effect a chance to also fire a
|
|
133
|
+
// second save — there shouldn't be one; applyExternal never saves.
|
|
134
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
135
|
+
|
|
136
|
+
const saved = adapter.saveCalls[0].project
|
|
137
|
+
const segs = saved.captions?.segments ?? []
|
|
138
|
+
expect(segs.map((s) => s.id)).toEqual(['cap-0', 'cap-2'])
|
|
139
|
+
// cap-2 was on row 2 with the hole below it (row 1, just emptied) — it
|
|
140
|
+
// drops into the vacated row instead of leaving row 1 sparse. This is the
|
|
141
|
+
// assertion that fails without the fix (cap-2 stuck at lane 2).
|
|
142
|
+
expect(segs.map((s) => s.lane ?? 0)).toEqual([0, 1])
|
|
143
|
+
// Exactly one save for the whole delete — densifying didn't cost a
|
|
144
|
+
// second commit.
|
|
145
|
+
expect(adapter.saveCalls.length).toBe(1)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act } from '@testing-library/react'
|
|
3
|
+
import type { EditorAdapter, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
4
|
+
import type { ImageElement } from '../../types'
|
|
5
|
+
import VideoEditor from '../VideoEditor'
|
|
6
|
+
|
|
7
|
+
// ── Timeline stand-in ────────────────────────────────────────────────────────
|
|
8
|
+
// A cross-row caption drag is driven entirely by the canvas pointer machine
|
|
9
|
+
// (pointer-machine.ts — owned elsewhere and not exercised here) calling
|
|
10
|
+
// Timeline's `onProjectChange` prop once per mousemove and `onOverlayEdit` on
|
|
11
|
+
// release. The bug this regresses, and its fix, live entirely in
|
|
12
|
+
// VideoEditor.tsx's own wiring around those two callbacks (captionGestureRef +
|
|
13
|
+
// the lane-normalization effect just above it) — not in the pointer machine
|
|
14
|
+
// itself. So instead of reproducing real canvas hit-testing in jsdom, this
|
|
15
|
+
// test replaces Timeline with a stand-in that just captures those two props,
|
|
16
|
+
// and drives them directly with hand-built projects that represent exactly
|
|
17
|
+
// what a real drag's mid-gesture frame and end-of-gesture commit look like.
|
|
18
|
+
let latestOnProjectChange: ((p: Project) => void) | undefined
|
|
19
|
+
let latestOnOverlayEdit: ((p: Project) => void) | undefined
|
|
20
|
+
vi.mock('../timeline/Timeline', () => ({
|
|
21
|
+
default: (props: { onProjectChange?: (p: Project) => void; onOverlayEdit?: (p: Project) => void }) => {
|
|
22
|
+
latestOnProjectChange = props.onProjectChange
|
|
23
|
+
latestOnOverlayEdit = props.onOverlayEdit
|
|
24
|
+
return null
|
|
25
|
+
},
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
29
|
+
return {
|
|
30
|
+
id: 'vid-1',
|
|
31
|
+
name: 'Test Video',
|
|
32
|
+
status: 'draft',
|
|
33
|
+
editingPrompt: '',
|
|
34
|
+
projectType: 'video',
|
|
35
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
36
|
+
tracks: [
|
|
37
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
38
|
+
],
|
|
39
|
+
audio: { tracks: [] },
|
|
40
|
+
assets: [],
|
|
41
|
+
...overrides,
|
|
42
|
+
} as Project
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeFakeAdapter(): EditorAdapter<Project> {
|
|
46
|
+
let subscribers: Array<(project: Project) => void> = []
|
|
47
|
+
return {
|
|
48
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
49
|
+
saveProject: vi.fn(async () => {}),
|
|
50
|
+
subscribe: (_id: string, onFrame: (project: Project) => void) => {
|
|
51
|
+
subscribers.push(onFrame)
|
|
52
|
+
return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
|
|
53
|
+
},
|
|
54
|
+
render: async function* (): AsyncIterable<RenderEvent> {
|
|
55
|
+
yield { type: 'done', outputPath: '/out.mp4' }
|
|
56
|
+
},
|
|
57
|
+
resolveImageSrc: (el: ImageElement) => el.src,
|
|
58
|
+
compileOverlay: vi.fn(async () => () => null),
|
|
59
|
+
listGlobalOverlays: vi.fn(async () => []),
|
|
60
|
+
listSystemOverlays: vi.fn(async () => []),
|
|
61
|
+
uploadFile: vi.fn(async () => '/path'),
|
|
62
|
+
fileUrl: (path: string) => path,
|
|
63
|
+
listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
|
|
64
|
+
restoreVersion: vi.fn(async () => makeVideoProject()),
|
|
65
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
66
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
67
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
68
|
+
} as unknown as EditorAdapter<Project>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
73
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
74
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
75
|
+
observe() {}
|
|
76
|
+
unobserve() {}
|
|
77
|
+
disconnect() {}
|
|
78
|
+
}
|
|
79
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
80
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
81
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
82
|
+
state = 'running'
|
|
83
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
84
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
85
|
+
get destination() { return {} }
|
|
86
|
+
close() {}
|
|
87
|
+
}
|
|
88
|
+
latestOnProjectChange = undefined
|
|
89
|
+
latestOnOverlayEdit = undefined
|
|
90
|
+
})
|
|
91
|
+
afterEach(() => vi.restoreAllMocks())
|
|
92
|
+
|
|
93
|
+
// Three dense rows, one caption each — the PRE-drag state undo must restore.
|
|
94
|
+
const preDragCaptions = {
|
|
95
|
+
style: 'clean',
|
|
96
|
+
segments: [
|
|
97
|
+
{ id: 's0', text: 'zero', start: 0, end: 1, lane: 0, words: [{ word: 'zero', start: 0, end: 1 }] },
|
|
98
|
+
{ id: 's1', text: 'one', start: 1, end: 2, lane: 1, words: [{ word: 'one', start: 1, end: 2 }] },
|
|
99
|
+
{ id: 's2', text: 'two', start: 2, end: 3, lane: 2, words: [{ word: 'two', start: 2, end: 3 }] },
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('VideoEditor — cross-row caption drag preserves its undo entry (FIX 1)', () => {
|
|
104
|
+
it('a mid-gesture frame that opens a hole lane does not corrupt the commit, and undo restores the pre-drag lanes', async () => {
|
|
105
|
+
const adapter = makeFakeAdapter()
|
|
106
|
+
const onProjectChange = vi.fn()
|
|
107
|
+
const initial = makeVideoProject({ captions: preDragCaptions } as Partial<Project>)
|
|
108
|
+
const { getByLabelText } = render(
|
|
109
|
+
<VideoEditor project={initial} adapter={adapter} onProjectChange={onProjectChange} />,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
await waitFor(() => expect(latestOnProjectChange).toBeTypeOf('function'))
|
|
113
|
+
await waitFor(() => expect(latestOnOverlayEdit).toBeTypeOf('function'))
|
|
114
|
+
|
|
115
|
+
// Nothing to undo before the gesture starts.
|
|
116
|
+
expect((getByLabelText('Undo') as HTMLButtonElement).disabled).toBe(true)
|
|
117
|
+
|
|
118
|
+
// Mid-drag frame: s1 dragged from row 1 up onto row 0. Row 1 is now a
|
|
119
|
+
// HOLE (no segments) while row 2 (s2) is left untouched — exactly what
|
|
120
|
+
// pointer-machine.ts deliberately leaves un-normalized for the length of
|
|
121
|
+
// the gesture, so the vacated row stays open as a drop target and the
|
|
122
|
+
// timeline doesn't jump under the pointer.
|
|
123
|
+
const midDragProject = {
|
|
124
|
+
...initial,
|
|
125
|
+
captions: {
|
|
126
|
+
...preDragCaptions,
|
|
127
|
+
segments: [
|
|
128
|
+
{ ...preDragCaptions.segments[0] },
|
|
129
|
+
{ ...preDragCaptions.segments[1], lane: 0 },
|
|
130
|
+
{ ...preDragCaptions.segments[2] },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
} as Project
|
|
134
|
+
await act(async () => { latestOnProjectChange!(midDragProject) })
|
|
135
|
+
|
|
136
|
+
// Gesture ends: the canvas pointer machine's own commit already closed
|
|
137
|
+
// the hole (row 2 renumbers to row 1). VideoEditor folds in
|
|
138
|
+
// auto-crossfade (a no-op here) and commits.
|
|
139
|
+
const committedProject = {
|
|
140
|
+
...initial,
|
|
141
|
+
captions: {
|
|
142
|
+
...preDragCaptions,
|
|
143
|
+
segments: [
|
|
144
|
+
{ ...preDragCaptions.segments[0] },
|
|
145
|
+
{ ...preDragCaptions.segments[1], lane: 0 },
|
|
146
|
+
{ ...preDragCaptions.segments[2], lane: 1 },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
} as Project
|
|
150
|
+
await act(async () => { latestOnOverlayEdit!(committedProject) })
|
|
151
|
+
|
|
152
|
+
// The gesture produced exactly one undo entry.
|
|
153
|
+
await waitFor(() => expect((getByLabelText('Undo') as HTMLButtonElement).disabled).toBe(false))
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Undo must land on the PRE-drag arrangement (s0:0, s1:1, s2:2) — not the
|
|
160
|
+
// mid-drag state, and not the committed state.
|
|
161
|
+
await waitFor(() => {
|
|
162
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
163
|
+
expect(last.captions?.segments.map((s) => [s.id, s.lane])).toEqual([
|
|
164
|
+
['s0', 0],
|
|
165
|
+
['s1', 1],
|
|
166
|
+
['s2', 2],
|
|
167
|
+
])
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
})
|