@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { render, waitFor, act, fireEvent, screen } from '@testing-library/react'
|
|
3
|
+
import type { EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
|
|
4
|
+
import VideoEditor from '../VideoEditor'
|
|
5
|
+
import { trackItems } from '../timeline/timeline-model'
|
|
6
|
+
import { installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
|
|
7
|
+
|
|
8
|
+
// ── T9 integration tests — the ReviewSurface keymap, the command palette,
|
|
9
|
+
// and the scrubber's "go to time" affordance, driven through a mounted
|
|
10
|
+
// <VideoEditor>. A minimal fake adapter is duplicated from VideoEditor.test.tsx
|
|
11
|
+
// (that file isn't touched — "existing tests unmodified") rather than shared.
|
|
12
|
+
|
|
13
|
+
function makeVideoProject(overrides: Partial<Project> = {}): Project {
|
|
14
|
+
return {
|
|
15
|
+
id: 'vid-1',
|
|
16
|
+
name: 'Test Video',
|
|
17
|
+
status: 'draft',
|
|
18
|
+
editingPrompt: '',
|
|
19
|
+
projectType: 'video',
|
|
20
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
21
|
+
tracks: [
|
|
22
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
23
|
+
[{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Hello' } }],
|
|
24
|
+
],
|
|
25
|
+
audio: { tracks: [] },
|
|
26
|
+
assets: [],
|
|
27
|
+
...overrides,
|
|
28
|
+
} as Project
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// T2 clipboard fixture: identical to `makeVideoProject`'s default tracks
|
|
32
|
+
// except `overlay-1` carries a distinguishing `opacity`, so a paste-attributes
|
|
33
|
+
// test can prove the TARGET actually picked up the SOURCE's look field
|
|
34
|
+
// (rather than merely asserting no error was thrown). Built the same way
|
|
35
|
+
// `makeVideoProject` builds its own default tracks (a whole-object `as
|
|
36
|
+
// Project` cast) rather than routed through `makeVideoProject`'s `overrides:
|
|
37
|
+
// Partial<Project>` parameter — the legacy array-of-arrays tracks shape isn't
|
|
38
|
+
// structurally assignable to `Partial<Project>`'s `VisualTrack[]`, even
|
|
39
|
+
// though `trackItems`/`normalizeTracks` accept both shapes at runtime.
|
|
40
|
+
function makeClipboardProject(): Project {
|
|
41
|
+
return {
|
|
42
|
+
...makeVideoProject(),
|
|
43
|
+
tracks: [
|
|
44
|
+
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
45
|
+
[{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Hello' }, opacity: 0.4 }],
|
|
46
|
+
],
|
|
47
|
+
// `as unknown as Project` (TS's own suggestion here): the legacy
|
|
48
|
+
// array-of-arrays tracks shape isn't structurally assignable to `Project`'s
|
|
49
|
+
// `VisualTrack[]`, even though `trackItems`/`normalizeTracks` accept both
|
|
50
|
+
// shapes at runtime — same reason `makeVideoProject`'s own default tracks
|
|
51
|
+
// only compile because its `...overrides: Partial<Project>` spread widens
|
|
52
|
+
// the inferred literal type enough for a plain `as Project` to pass.
|
|
53
|
+
} as unknown as Project
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeFakeAdapter(): EditorAdapter<Project> {
|
|
57
|
+
return {
|
|
58
|
+
loadProject: vi.fn(async () => makeVideoProject()),
|
|
59
|
+
saveProject: vi.fn(async () => {}),
|
|
60
|
+
subscribe: () => () => {},
|
|
61
|
+
render: async function* (): AsyncIterable<RenderEvent> { yield { type: 'done', outputPath: '/out.mp4' } },
|
|
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 () => makeVideoProject()),
|
|
70
|
+
getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
|
|
71
|
+
resolveCaptionTemplate: (style: string) => `/caption/${style}`,
|
|
72
|
+
getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Stashed by `renderEditor` so `selectOverlay`/`selectVideoClip` can address a
|
|
77
|
+
// clip by canvas geometry without every call site threading `container` and
|
|
78
|
+
// `project` through by hand. Reset in `afterEach` below.
|
|
79
|
+
let selCtx: { container: HTMLElement; project: Project } | null = null
|
|
80
|
+
|
|
81
|
+
/** Canvas-mode replacement for a bare `render(<VideoEditor ... />)` at every
|
|
82
|
+
* call site in this file that later selects a clip — mounts as usual, then
|
|
83
|
+
* stashes `{ container, project }` for `selectOverlay`/`selectVideoClip`. */
|
|
84
|
+
function renderEditor(ui: Parameters<typeof render>[0], project: Project): ReturnType<typeof render> {
|
|
85
|
+
const result = render(ui)
|
|
86
|
+
selCtx = { container: result.container, project }
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let uninstallCanvasHarness: () => void
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
uninstallCanvasHarness = installCanvasHarness()
|
|
94
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
95
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
96
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
97
|
+
observe() {}
|
|
98
|
+
unobserve() {}
|
|
99
|
+
disconnect() {}
|
|
100
|
+
}
|
|
101
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
102
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
103
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
104
|
+
state = 'running'
|
|
105
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
106
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
107
|
+
get destination() { return {} }
|
|
108
|
+
close() {}
|
|
109
|
+
}
|
|
110
|
+
// T5 fullscreen — jsdom has no real Fullscreen API. Stubbed so
|
|
111
|
+
// `requestFullscreen`/`exitFullscreen` do the one thing VideoEditor relies
|
|
112
|
+
// on (real browsers do this natively): flip `document.fullscreenElement`
|
|
113
|
+
// and fire `fullscreenchange`, synchronously, so the effect that reads it
|
|
114
|
+
// settles inside the same `act()` as the call that triggered it.
|
|
115
|
+
;(HTMLElement.prototype as unknown as { requestFullscreen: () => Promise<void> }).requestFullscreen =
|
|
116
|
+
vi.fn(function (this: HTMLElement) {
|
|
117
|
+
Object.defineProperty(document, 'fullscreenElement', { value: this, configurable: true })
|
|
118
|
+
document.dispatchEvent(new Event('fullscreenchange'))
|
|
119
|
+
return Promise.resolve()
|
|
120
|
+
})
|
|
121
|
+
document.exitFullscreen = vi.fn(() => {
|
|
122
|
+
Object.defineProperty(document, 'fullscreenElement', { value: null, configurable: true })
|
|
123
|
+
document.dispatchEvent(new Event('fullscreenchange'))
|
|
124
|
+
return Promise.resolve()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
vi.restoreAllMocks()
|
|
129
|
+
Object.defineProperty(document, 'fullscreenElement', { value: null, configurable: true })
|
|
130
|
+
uninstallCanvasHarness()
|
|
131
|
+
selCtx = null
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
function requireSelCtx(caller: string): { container: HTMLElement; project: Project } {
|
|
135
|
+
if (!selCtx) throw new Error(`[VideoEditor.keymap.test] ${caller}() called before renderEditor()`)
|
|
136
|
+
return selCtx
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Canvas-native replacement for `fireEvent.click(await screen.findByText('▪
|
|
140
|
+
// overlay'), { metaKey: true })` — additive (extends rather than replaces the
|
|
141
|
+
// selection), matching the pointer machine's own `isAdditive`.
|
|
142
|
+
async function selectOverlay() {
|
|
143
|
+
const { container, project } = requireSelCtx('selectOverlay')
|
|
144
|
+
selectCanvasItem(container, project, { type: 'overlay' }, { metaKey: true })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A PLAIN click (no meta/shift) replaces the selection outright — see
|
|
148
|
+
// `toggleSelection` (multiSelectOps.ts): additive-false always resolves to
|
|
149
|
+
// `[id]`. Used by the T2 tests below to move the selection off the clipboard
|
|
150
|
+
// source and onto a distinct paste-attributes TARGET.
|
|
151
|
+
async function selectVideoClip() {
|
|
152
|
+
const { container, project } = requireSelCtx('selectVideoClip')
|
|
153
|
+
selectCanvasItem(container, project, { type: 'video' })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Timeline's own Delete/Enter bindings are focus-scoped to its root (the
|
|
157
|
+
* `tabIndex={0}` container) — see Timeline.tsx. Selecting via the preview
|
|
158
|
+
* (as `selectOverlay` does above) never touches that container, so a plain
|
|
159
|
+
* Delete needs focus established explicitly here, mirroring how a real
|
|
160
|
+
* operator would have clicked a timeline row first. It's the only
|
|
161
|
+
* `tabIndex={0}` element this tree renders (TimelineCanvas's own container
|
|
162
|
+
* uses `tabIndex={-1}`, and isn't mounted in DOM mode anyway). */
|
|
163
|
+
function focusTimelineRoot() {
|
|
164
|
+
const root = document.querySelector('[tabindex="0"]') as HTMLElement | null
|
|
165
|
+
root?.focus()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe('VideoEditor — T9 ripple-delete binding', () => {
|
|
169
|
+
it('Shift+Delete ripple-deletes the selected item and commits (persists) exactly once', async () => {
|
|
170
|
+
const adapter = makeFakeAdapter()
|
|
171
|
+
const onProjectChange = vi.fn()
|
|
172
|
+
const project = makeVideoProject()
|
|
173
|
+
renderEditor(
|
|
174
|
+
<VideoEditor
|
|
175
|
+
project={project}
|
|
176
|
+
adapter={adapter}
|
|
177
|
+
onProjectChange={onProjectChange}
|
|
178
|
+
slots={{ exportActions: <div /> }}
|
|
179
|
+
/>,
|
|
180
|
+
project,
|
|
181
|
+
)
|
|
182
|
+
await selectOverlay()
|
|
183
|
+
|
|
184
|
+
await act(async () => {
|
|
185
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: true }))
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
await waitFor(() => {
|
|
189
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
190
|
+
expect(last.tracks?.flat().find((i) => i.id === 'overlay-1')).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
// Exactly one commit for the gesture — one save call.
|
|
193
|
+
await waitFor(() => expect((adapter.saveProject as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('Shift+Delete with no selection is a no-op', async () => {
|
|
197
|
+
const adapter = makeFakeAdapter()
|
|
198
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
199
|
+
await screen.findByLabelText('Preview axis')
|
|
200
|
+
|
|
201
|
+
await act(async () => {
|
|
202
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: true }))
|
|
203
|
+
})
|
|
204
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('plain Delete (no Shift) still runs the ORIGINAL two-step delete, not ripple-delete', async () => {
|
|
208
|
+
// Regression guard for the Shift-exclusion added to Timeline's delete
|
|
209
|
+
// matcher (see keymap.ts's matchesDelete) — without it, plain Delete
|
|
210
|
+
// would ALSO match a binding meant only for Shift+Delete, or vice versa.
|
|
211
|
+
const adapter = makeFakeAdapter()
|
|
212
|
+
const onProjectChange = vi.fn()
|
|
213
|
+
const project = makeVideoProject()
|
|
214
|
+
renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />, project)
|
|
215
|
+
await selectOverlay()
|
|
216
|
+
focusTimelineRoot()
|
|
217
|
+
|
|
218
|
+
await act(async () => {
|
|
219
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: false }))
|
|
220
|
+
})
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
223
|
+
expect(last.tracks?.flat().find((i) => i.id === 'overlay-1')).toBeUndefined()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('VideoEditor — T9 command palette', () => {
|
|
229
|
+
it('Cmd+K opens the palette listing commands', async () => {
|
|
230
|
+
const adapter = makeFakeAdapter()
|
|
231
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
232
|
+
await screen.findByLabelText('Preview axis')
|
|
233
|
+
|
|
234
|
+
await act(async () => {
|
|
235
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
|
236
|
+
})
|
|
237
|
+
expect(await screen.findByText('Split at playhead')).toBeTruthy()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('typing a filter letter into the palette does not also split the timeline', async () => {
|
|
241
|
+
const adapter = makeFakeAdapter()
|
|
242
|
+
const onProjectChange = vi.fn()
|
|
243
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
|
|
244
|
+
await screen.findByLabelText('Preview axis')
|
|
245
|
+
|
|
246
|
+
await act(async () => {
|
|
247
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
|
248
|
+
})
|
|
249
|
+
const input = await screen.findByPlaceholderText('Type a command…')
|
|
250
|
+
onProjectChange.mockClear()
|
|
251
|
+
fireEvent.keyDown(input, { key: 's' })
|
|
252
|
+
fireEvent.change(input, { target: { value: 's' } })
|
|
253
|
+
// 's' typed into the palette's OWN input must filter, not split — split
|
|
254
|
+
// would show up as an onProjectChange carrying a new track split at 0.
|
|
255
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('Escape closes the palette', async () => {
|
|
259
|
+
const adapter = makeFakeAdapter()
|
|
260
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
261
|
+
await screen.findByLabelText('Preview axis')
|
|
262
|
+
|
|
263
|
+
await act(async () => {
|
|
264
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
|
265
|
+
})
|
|
266
|
+
const input = await screen.findByPlaceholderText('Type a command…')
|
|
267
|
+
fireEvent.keyDown(input, { key: 'Escape' })
|
|
268
|
+
await waitFor(() => expect(screen.queryByPlaceholderText('Type a command…')).toBeNull())
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('clicking the scrubber time readout opens the palette directly in "go to time" mode', async () => {
|
|
272
|
+
const adapter = makeFakeAdapter()
|
|
273
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
274
|
+
await screen.findByLabelText('Preview axis')
|
|
275
|
+
|
|
276
|
+
const readout = await screen.findByLabelText('Go to time')
|
|
277
|
+
fireEvent.click(readout)
|
|
278
|
+
expect(await screen.findByPlaceholderText(/mm:ss/)).toBeTruthy()
|
|
279
|
+
// The list view's search box must NOT also be present — it opened
|
|
280
|
+
// straight into goto mode, not the filtered list.
|
|
281
|
+
expect(screen.queryByPlaceholderText('Type a command…')).toBeNull()
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe('VideoEditor — T9 keymap does not race Space', () => {
|
|
286
|
+
it('Space is never observed by the keymap (no palette, no split, no undo/redo side effect)', async () => {
|
|
287
|
+
const adapter = makeFakeAdapter()
|
|
288
|
+
const onProjectChange = vi.fn()
|
|
289
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
|
|
290
|
+
await screen.findByLabelText('Preview axis')
|
|
291
|
+
onProjectChange.mockClear()
|
|
292
|
+
|
|
293
|
+
await act(async () => {
|
|
294
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space' }))
|
|
295
|
+
})
|
|
296
|
+
// No palette opened, no split committed as a side effect of Space.
|
|
297
|
+
expect(screen.queryByPlaceholderText('Type a command…')).toBeNull()
|
|
298
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// ── The preview-axis toggle ──────────────────────────────────────────────
|
|
303
|
+
//
|
|
304
|
+
// The gesture RULES are covered as pure data in `pointer-machine.test.ts`.
|
|
305
|
+
// What's only observable at this level is the chrome: the toggle exists, it
|
|
306
|
+
// starts off, and the toolbar button / Cmd+S / the palette all drive the same
|
|
307
|
+
// piece of state.
|
|
308
|
+
|
|
309
|
+
describe('VideoEditor — preview axis toggle', () => {
|
|
310
|
+
it('starts OFF, so clicking the timeline does not scrub by default', async () => {
|
|
311
|
+
const adapter = makeFakeAdapter()
|
|
312
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
313
|
+
await screen.findByLabelText('Preview axis')
|
|
314
|
+
|
|
315
|
+
expect(screen.getByLabelText('Preview axis').getAttribute('aria-pressed')).toBe('false')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('the toolbar button toggles it', async () => {
|
|
319
|
+
const adapter = makeFakeAdapter()
|
|
320
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
321
|
+
await screen.findByLabelText('Preview axis')
|
|
322
|
+
|
|
323
|
+
const button = screen.getByLabelText('Preview axis')
|
|
324
|
+
fireEvent.click(button)
|
|
325
|
+
expect(button.getAttribute('aria-pressed')).toBe('true')
|
|
326
|
+
fireEvent.click(button)
|
|
327
|
+
expect(button.getAttribute('aria-pressed')).toBe('false')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('Cmd+A and Ctrl+A both toggle it — A for Axis', async () => {
|
|
331
|
+
const adapter = makeFakeAdapter()
|
|
332
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
333
|
+
await screen.findByLabelText('Preview axis')
|
|
334
|
+
|
|
335
|
+
const button = screen.getByLabelText('Preview axis')
|
|
336
|
+
await act(async () => {
|
|
337
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', metaKey: true }))
|
|
338
|
+
})
|
|
339
|
+
expect(button.getAttribute('aria-pressed')).toBe('true')
|
|
340
|
+
|
|
341
|
+
await act(async () => {
|
|
342
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true }))
|
|
343
|
+
})
|
|
344
|
+
expect(button.getAttribute('aria-pressed')).toBe('false')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('leaves Cmd+A alone inside a typing surface, so Select All still works there', async () => {
|
|
348
|
+
// The chord shadows the browser's Select All everywhere EXCEPT text entry.
|
|
349
|
+
// `isTypingTarget` is what draws that line, and a caption row is a real
|
|
350
|
+
// contentEditable in this surface — regressing the guard would make it
|
|
351
|
+
// impossible to select the text of a caption you are editing.
|
|
352
|
+
const adapter = makeFakeAdapter()
|
|
353
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
354
|
+
await screen.findByLabelText('Preview axis')
|
|
355
|
+
|
|
356
|
+
const input = document.createElement('input')
|
|
357
|
+
document.body.appendChild(input)
|
|
358
|
+
await act(async () => {
|
|
359
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', metaKey: true, bubbles: true }))
|
|
360
|
+
})
|
|
361
|
+
expect(screen.getByLabelText('Preview axis').getAttribute('aria-pressed')).toBe('false')
|
|
362
|
+
input.remove()
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('bare A does nothing — the toggle is the chord, not the letter', async () => {
|
|
366
|
+
const adapter = makeFakeAdapter()
|
|
367
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
368
|
+
await screen.findByLabelText('Preview axis')
|
|
369
|
+
|
|
370
|
+
await act(async () => {
|
|
371
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
|
|
372
|
+
})
|
|
373
|
+
expect(screen.getByLabelText('Preview axis').getAttribute('aria-pressed')).toBe('false')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('offers the toggle in the command palette, labelled by what it will do', async () => {
|
|
377
|
+
const adapter = makeFakeAdapter()
|
|
378
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
379
|
+
await screen.findByLabelText('Preview axis')
|
|
380
|
+
|
|
381
|
+
await act(async () => {
|
|
382
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
|
383
|
+
})
|
|
384
|
+
fireEvent.click(await screen.findByText('Preview axis: turn on'))
|
|
385
|
+
expect(screen.getByLabelText('Preview axis').getAttribute('aria-pressed')).toBe('true')
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// ── T2 — copy / paste / duplicate / paste-attributes ────────────────────
|
|
390
|
+
//
|
|
391
|
+
// Persistence assertions read `adapter.saveProject`'s SECOND argument (the
|
|
392
|
+
// project actually being saved), not `onProjectChange` — an optimistic
|
|
393
|
+
// `onProjectChange` fire can't tell a real commit apart from a client-side
|
|
394
|
+
// normalizer patch that never saves (see the load-time id-backfill/caption-
|
|
395
|
+
// repair effects elsewhere in VideoEditor.tsx).
|
|
396
|
+
|
|
397
|
+
describe('VideoEditor — T2 copy/paste/duplicate', () => {
|
|
398
|
+
it('Cmd+C never persists on its own — copying is local clipboard state only', async () => {
|
|
399
|
+
const adapter = makeFakeAdapter()
|
|
400
|
+
const project = makeVideoProject()
|
|
401
|
+
renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />, project)
|
|
402
|
+
await selectVideoClip()
|
|
403
|
+
|
|
404
|
+
await act(async () => {
|
|
405
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }))
|
|
406
|
+
})
|
|
407
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('Cmd+V with an empty clipboard is a no-op', async () => {
|
|
411
|
+
const adapter = makeFakeAdapter()
|
|
412
|
+
const project = makeVideoProject()
|
|
413
|
+
renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />, project)
|
|
414
|
+
await selectVideoClip()
|
|
415
|
+
|
|
416
|
+
await act(async () => {
|
|
417
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
|
|
418
|
+
})
|
|
419
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('Cmd+C then Cmd+V pastes a copy at the playhead and commits it exactly once', async () => {
|
|
423
|
+
const adapter = makeFakeAdapter()
|
|
424
|
+
const project = makeVideoProject()
|
|
425
|
+
renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />, project)
|
|
426
|
+
await selectVideoClip()
|
|
427
|
+
|
|
428
|
+
await act(async () => {
|
|
429
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }))
|
|
430
|
+
})
|
|
431
|
+
await act(async () => {
|
|
432
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(1))
|
|
436
|
+
const saved = (adapter.saveProject as ReturnType<typeof vi.fn>).mock.calls[0][1] as Project
|
|
437
|
+
const videoItems = trackItems(saved).flat().filter((i) => i.type === 'video')
|
|
438
|
+
// The original plus one pasted copy — a fresh id, not a re-used one.
|
|
439
|
+
expect(videoItems).toHaveLength(2)
|
|
440
|
+
expect(videoItems.map((i) => i.id)).toContain('clip-0')
|
|
441
|
+
expect(videoItems.filter((i) => i.id !== 'clip-0')).toHaveLength(1)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('Cmd+D with no selection is a no-op', async () => {
|
|
445
|
+
const adapter = makeFakeAdapter()
|
|
446
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
447
|
+
await screen.findByLabelText('Preview axis')
|
|
448
|
+
|
|
449
|
+
await act(async () => {
|
|
450
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', metaKey: true }))
|
|
451
|
+
})
|
|
452
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// Every test in this file is driven through the CANVAS timeline rather
|
|
456
|
+
// than the DOM rows — see `renderEditor`/`selectOverlay`/`selectVideoClip`
|
|
457
|
+
// near the top of the file, which route through `selectCanvasItem` (see
|
|
458
|
+
// `timeline/__tests__/_canvasSelect.ts`). It computes a clip's page
|
|
459
|
+
// coordinates from the same layout + viewport math the surface paints
|
|
460
|
+
// through, so selecting `clip-0` here lands exactly where clicking
|
|
461
|
+
// "▪ video" used to — the assertions below are untouched.
|
|
462
|
+
//
|
|
463
|
+
// Canvas is simply what a timeline is now — there is no mode to opt into.
|
|
464
|
+
it('Cmd+D duplicates the selection in place and commits it exactly once', async () => {
|
|
465
|
+
const adapter = makeFakeAdapter()
|
|
466
|
+
const project = makeVideoProject()
|
|
467
|
+
renderEditor(
|
|
468
|
+
<VideoEditor
|
|
469
|
+
project={project}
|
|
470
|
+
adapter={adapter}
|
|
471
|
+
onProjectChange={vi.fn()}
|
|
472
|
+
slots={{ exportActions: <div /> }}
|
|
473
|
+
/>,
|
|
474
|
+
project,
|
|
475
|
+
)
|
|
476
|
+
await selectVideoClip()
|
|
477
|
+
|
|
478
|
+
await act(async () => {
|
|
479
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', metaKey: true }))
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(1))
|
|
483
|
+
const saved = (adapter.saveProject as ReturnType<typeof vi.fn>).mock.calls[0][1] as Project
|
|
484
|
+
const videoItems = trackItems(saved).flat().filter((i) => i.type === 'video')
|
|
485
|
+
expect(videoItems).toHaveLength(2)
|
|
486
|
+
expect(videoItems.filter((i) => i.id !== 'clip-0')).toHaveLength(1)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('Cmd+Opt+V with neither a clipboard nor a selection is a no-op', async () => {
|
|
490
|
+
const adapter = makeFakeAdapter()
|
|
491
|
+
render(<VideoEditor project={makeClipboardProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
492
|
+
await screen.findByLabelText('Preview axis')
|
|
493
|
+
|
|
494
|
+
await act(async () => {
|
|
495
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true, altKey: true }))
|
|
496
|
+
})
|
|
497
|
+
expect(adapter.saveProject).not.toHaveBeenCalled()
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('Cmd+Opt+V pastes ATTRIBUTES onto the selected target and does NOT fall through to a plain paste', async () => {
|
|
501
|
+
// Regression guard for the binding-ORDER requirement in keymap.ts's
|
|
502
|
+
// `matchesModAltKey` doc comment: `matchesModKey('v')` (plain paste)
|
|
503
|
+
// doesn't exclude `altKey`, so Cmd+Opt+V matches BOTH bindings — only
|
|
504
|
+
// registering paste-attributes FIRST in the `useKeymap` array makes it
|
|
505
|
+
// win. Proven two ways at once: the target's `opacity` picks up the
|
|
506
|
+
// clipboard source's value, AND no third item is created (a plain paste
|
|
507
|
+
// would have added one).
|
|
508
|
+
const adapter = makeFakeAdapter()
|
|
509
|
+
const project = makeClipboardProject()
|
|
510
|
+
renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />, project)
|
|
511
|
+
await selectOverlay() // selects overlay-1 (opacity 0.4) — the copy SOURCE
|
|
512
|
+
await act(async () => {
|
|
513
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }))
|
|
514
|
+
})
|
|
515
|
+
await selectVideoClip() // plain click replaces the selection with clip-0 — the TARGET
|
|
516
|
+
|
|
517
|
+
await act(async () => {
|
|
518
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true, altKey: true }))
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(1))
|
|
522
|
+
const saved = (adapter.saveProject as ReturnType<typeof vi.fn>).mock.calls[0][1] as Project
|
|
523
|
+
const allItems = trackItems(saved).flat()
|
|
524
|
+
expect(allItems).toHaveLength(2) // unchanged — proves this was NOT a paste
|
|
525
|
+
expect(allItems.find((i) => i.id === 'clip-0')?.opacity).toBe(0.4)
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
// ── T5 — fullscreen preview ──────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
describe('VideoEditor — T5 fullscreen preview', () => {
|
|
532
|
+
it('starts out of fullscreen', async () => {
|
|
533
|
+
const adapter = makeFakeAdapter()
|
|
534
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
535
|
+
await screen.findByLabelText('Preview axis')
|
|
536
|
+
|
|
537
|
+
expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('false')
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('F requests fullscreen on the preview, and F again exits it via document.exitFullscreen', async () => {
|
|
541
|
+
const adapter = makeFakeAdapter()
|
|
542
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
543
|
+
await screen.findByLabelText('Preview axis')
|
|
544
|
+
|
|
545
|
+
await act(async () => {
|
|
546
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f' }))
|
|
547
|
+
})
|
|
548
|
+
expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('true')
|
|
549
|
+
|
|
550
|
+
await act(async () => {
|
|
551
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f' }))
|
|
552
|
+
})
|
|
553
|
+
expect(document.exitFullscreen).toHaveBeenCalledTimes(1)
|
|
554
|
+
expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('false')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('Cmd+F is left alone — F only fires unmodified, so native browser find still works', async () => {
|
|
558
|
+
const adapter = makeFakeAdapter()
|
|
559
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
560
|
+
await screen.findByLabelText('Preview axis')
|
|
561
|
+
|
|
562
|
+
await act(async () => {
|
|
563
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', metaKey: true }))
|
|
564
|
+
})
|
|
565
|
+
expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('false')
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('the fullscreen button in the preview controls row also toggles it', async () => {
|
|
569
|
+
const adapter = makeFakeAdapter()
|
|
570
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
571
|
+
await screen.findByLabelText('Preview axis')
|
|
572
|
+
|
|
573
|
+
const button = screen.getByLabelText('Toggle fullscreen')
|
|
574
|
+
fireEvent.click(button)
|
|
575
|
+
expect(button.getAttribute('aria-pressed')).toBe('true')
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('a browser-driven fullscreenchange (Escape, tab switch) updates state truthfully — no Escape handling of our own', async () => {
|
|
579
|
+
const adapter = makeFakeAdapter()
|
|
580
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
581
|
+
await screen.findByLabelText('Preview axis')
|
|
582
|
+
|
|
583
|
+
await act(async () => {
|
|
584
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f' }))
|
|
585
|
+
})
|
|
586
|
+
expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('true')
|
|
587
|
+
|
|
588
|
+
// The browser exits fullscreen entirely on its own here — VideoEditor's
|
|
589
|
+
// own `exitFullscreen` mock is never called, only the native event fires.
|
|
590
|
+
await act(async () => {
|
|
591
|
+
Object.defineProperty(document, 'fullscreenElement', { value: null, configurable: true })
|
|
592
|
+
document.dispatchEvent(new Event('fullscreenchange'))
|
|
593
|
+
})
|
|
594
|
+
expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('false')
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('offers the toggle in the command palette, labelled by what it will do', async () => {
|
|
598
|
+
const adapter = makeFakeAdapter()
|
|
599
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
600
|
+
await screen.findByLabelText('Preview axis')
|
|
601
|
+
|
|
602
|
+
await act(async () => {
|
|
603
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
|
604
|
+
})
|
|
605
|
+
fireEvent.click(await screen.findByText('Enter fullscreen'))
|
|
606
|
+
expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('true')
|
|
607
|
+
|
|
608
|
+
await act(async () => {
|
|
609
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
|
|
610
|
+
})
|
|
611
|
+
expect(await screen.findByText('Exit fullscreen')).toBeTruthy()
|
|
612
|
+
})
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
// ── Preview controls row — timecode, zoom-to-fit, safe-zone toggle ────────
|
|
616
|
+
|
|
617
|
+
describe('VideoEditor — preview controls row', () => {
|
|
618
|
+
it('renders a current / total timecode readout that updates as the playhead moves', async () => {
|
|
619
|
+
const adapter = makeFakeAdapter()
|
|
620
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
621
|
+
await screen.findByLabelText('Preview axis')
|
|
622
|
+
|
|
623
|
+
// makeVideoProject's clips run 0-4, so content duration is 4s.
|
|
624
|
+
expect(screen.getByTestId('preview-timecode').textContent).toBe('0:00.0 / 0:04.0')
|
|
625
|
+
|
|
626
|
+
// Seek via the command palette's "go to time" entry (clock.set under the
|
|
627
|
+
// hood) — the readout's `usePlaybackTime(clock)` subscription should
|
|
628
|
+
// pick the new position up without any other interaction.
|
|
629
|
+
fireEvent.click(await screen.findByLabelText('Go to time'))
|
|
630
|
+
const input = await screen.findByPlaceholderText(/mm:ss/)
|
|
631
|
+
fireEvent.change(input, { target: { value: '2' } })
|
|
632
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
633
|
+
|
|
634
|
+
await waitFor(() => expect(screen.getByTestId('preview-timecode').textContent).toBe('0:02.0 / 0:04.0'))
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
it('clamps the DISPLAYED current time to the total, never showing more than it, while parked in the timeline canvas headroom', async () => {
|
|
638
|
+
const adapter = makeFakeAdapter()
|
|
639
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
640
|
+
await screen.findByLabelText('Preview axis')
|
|
641
|
+
|
|
642
|
+
// makeVideoProject's clips run 0-4 (content duration 4s), but the go-to-time
|
|
643
|
+
// clamp uses `getTotalDuration()` — content duration plus drag headroom for
|
|
644
|
+
// the timeline canvas (max(5, contentDuration * 0.2), so 9s here) — not the
|
|
645
|
+
// 4s shown as the readout's total. Seeking to 6 lands the playhead PAST the
|
|
646
|
+
// readout's total, entirely inside that headroom.
|
|
647
|
+
fireEvent.click(await screen.findByLabelText('Go to time'))
|
|
648
|
+
const input = await screen.findByPlaceholderText(/mm:ss/)
|
|
649
|
+
fireEvent.change(input, { target: { value: '6' } })
|
|
650
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
651
|
+
|
|
652
|
+
// The readout must never show a current time past its own total — clamped
|
|
653
|
+
// display only; the clock and total are untouched (proven by the sibling
|
|
654
|
+
// test above landing exactly on an UN-clamped in-range seek).
|
|
655
|
+
await waitFor(() => expect(screen.getByTestId('preview-timecode').textContent).toBe('0:04.0 / 0:04.0'))
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it('the social-preview picker shows and hides the chrome overlay over the video, off ("None") by default', async () => {
|
|
659
|
+
const adapter = makeFakeAdapter()
|
|
660
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
661
|
+
await screen.findByLabelText('Preview axis')
|
|
662
|
+
|
|
663
|
+
expect(screen.queryByTestId('social-safe-zone-overlay')).toBeNull()
|
|
664
|
+
const trigger = screen.getByLabelText('Preview for social media')
|
|
665
|
+
expect(trigger.getAttribute('aria-pressed')).toBe('false')
|
|
666
|
+
|
|
667
|
+
// Open the picker and choose TikTok — the chrome overlay mounts and the
|
|
668
|
+
// trigger reflects an active selection.
|
|
669
|
+
fireEvent.click(trigger)
|
|
670
|
+
fireEvent.click(screen.getByLabelText('TikTok'))
|
|
671
|
+
expect(trigger.getAttribute('aria-pressed')).toBe('true')
|
|
672
|
+
expect(screen.getByTestId('social-safe-zone-overlay')).toBeTruthy()
|
|
673
|
+
|
|
674
|
+
// Reopen and choose None — the overlay unmounts and the trigger returns
|
|
675
|
+
// to its inactive state.
|
|
676
|
+
fireEvent.click(trigger)
|
|
677
|
+
fireEvent.click(screen.getByLabelText('None'))
|
|
678
|
+
expect(trigger.getAttribute('aria-pressed')).toBe('false')
|
|
679
|
+
expect(screen.queryByTestId('social-safe-zone-overlay')).toBeNull()
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it('the social-preview pick persists into project settings (mirrors handleImageToneChange)', async () => {
|
|
683
|
+
const adapter = makeFakeAdapter()
|
|
684
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
685
|
+
await screen.findByLabelText('Preview axis')
|
|
686
|
+
|
|
687
|
+
fireEvent.click(screen.getByLabelText('Preview for social media'))
|
|
688
|
+
fireEvent.click(screen.getByLabelText('Instagram Reels'))
|
|
689
|
+
|
|
690
|
+
await waitFor(() => expect(adapter.saveProject).toHaveBeenCalled())
|
|
691
|
+
// saveProject(projectId, project) — the project itself is the SECOND arg.
|
|
692
|
+
const calls = (adapter.saveProject as ReturnType<typeof vi.fn>).mock.calls
|
|
693
|
+
const saved = calls[calls.length - 1]?.[1]
|
|
694
|
+
expect(saved.settings.socialPreview).toBe('instagram')
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it("the timeline chrome's 'fit' button resets the zoom — the single consolidated zoom-to-fit control (the duplicate preview-row icon was removed)", async () => {
|
|
698
|
+
// Canvas zoom chrome, which is now the only zoom chrome. The literals
|
|
699
|
+
// below are NOT the DOM path's: that badge printed an integer and stepped
|
|
700
|
+
// by +1 (1× → 2×), whereas canvas prints one decimal
|
|
701
|
+
// (`formatZoomMultiple`, canvas/viewport.ts:209-213) and steps by a
|
|
702
|
+
// multiplicative `ZOOM_BUTTON_FACTOR = 1.5` (canvas/viewport.ts:60) —
|
|
703
|
+
// hence 1.0× → 1.5×, not 1× → 2×. Not a user-visible change: canvas has
|
|
704
|
+
// been the shipping path all along, so this is the formatting the app has
|
|
705
|
+
// always shown. Only this test still encoded the dead DOM path's chrome.
|
|
706
|
+
// (The canvas harness itself comes from the file-level `beforeEach` above
|
|
707
|
+
// — every test in this file gets it, not just this one.)
|
|
708
|
+
const adapter = makeFakeAdapter()
|
|
709
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
|
|
710
|
+
await screen.findByLabelText('Preview axis')
|
|
711
|
+
|
|
712
|
+
expect(screen.getByLabelText('Zoom in').closest('div')?.textContent).toContain('1.0×')
|
|
713
|
+
fireEvent.click(screen.getByLabelText('Zoom in'))
|
|
714
|
+
expect(screen.getByLabelText('Zoom in').closest('div')?.textContent).toContain('1.5×')
|
|
715
|
+
|
|
716
|
+
// "fit" (aria-label "Fit to view") lives next to the +/- zoom buttons in
|
|
717
|
+
// the timeline chrome; the preview controls row no longer carries a
|
|
718
|
+
// duplicate zoom-to-fit icon. Canvas sets `showFit: true` unconditionally
|
|
719
|
+
// (TimelineCanvas.tsx:1150-1152) — deliberately, because canvas zoom can
|
|
720
|
+
// sit BELOW 1×, so "am I off fit?" is not simply "is zoom > 1" as it was
|
|
721
|
+
// on the DOM path.
|
|
722
|
+
fireEvent.click(screen.getByLabelText('Fit to view'))
|
|
723
|
+
expect(screen.getByLabelText('Zoom in').closest('div')?.textContent).toContain('1.0×')
|
|
724
|
+
})
|
|
725
|
+
})
|