@bycrux/editor 0.12.0 → 1.0.2
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 +161 -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,407 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* SP5 T4 — the canvas surface's wiring. The painter and the viewport math are
|
|
4
|
+
* covered as pure units elsewhere; what's asserted here is the part only a
|
|
5
|
+
* mounted component can show: that redraws happen imperatively.
|
|
6
|
+
*
|
|
7
|
+
* jsdom has no 2D canvas and no ResizeObserver, so both are stubbed — the
|
|
8
|
+
* component's own scheduling, subscriptions and layer split are what's under
|
|
9
|
+
* test, not the rasterizer.
|
|
10
|
+
*/
|
|
11
|
+
import { StrictMode } from 'react'
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
13
|
+
import { render, act, cleanup } from '@testing-library/react'
|
|
14
|
+
import { createPlaybackClock } from '../../../playback-clock'
|
|
15
|
+
import type { Project } from '../../../../types'
|
|
16
|
+
import TimelineCanvas from '../TimelineCanvas'
|
|
17
|
+
import { LIGHT_TIMELINE_COLORS, TIMELINE_COLORS, type TimelineMode } from '../draw'
|
|
18
|
+
import { createViewportStore, type ViewportStore } from '../viewport'
|
|
19
|
+
|
|
20
|
+
interface RecordedCall { method: string; args: unknown[] }
|
|
21
|
+
|
|
22
|
+
const recorders = new Map<HTMLCanvasElement, RecordedCall[]>()
|
|
23
|
+
let realGetContext: typeof HTMLCanvasElement.prototype.getContext
|
|
24
|
+
let realGetRect: typeof Element.prototype.getBoundingClientRect
|
|
25
|
+
|
|
26
|
+
function recorderFor(canvas: HTMLCanvasElement): RecordedCall[] {
|
|
27
|
+
const existing = recorders.get(canvas)
|
|
28
|
+
if (existing) return existing
|
|
29
|
+
const calls: RecordedCall[] = []
|
|
30
|
+
recorders.set(canvas, calls)
|
|
31
|
+
return calls
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function contextFor(canvas: HTMLCanvasElement) {
|
|
35
|
+
const calls = recorderFor(canvas)
|
|
36
|
+
const props: Record<string, unknown> = {}
|
|
37
|
+
return new Proxy({}, {
|
|
38
|
+
get(_t, prop: string) {
|
|
39
|
+
if (prop in props) return props[prop]
|
|
40
|
+
if (prop === 'createLinearGradient') {
|
|
41
|
+
return () => ({ addColorStop: () => {} })
|
|
42
|
+
}
|
|
43
|
+
return (...args: unknown[]) => { calls.push({ method: prop, args }) }
|
|
44
|
+
},
|
|
45
|
+
set(_t, prop: string, value: unknown) {
|
|
46
|
+
props[prop] = value
|
|
47
|
+
calls.push({ method: `set:${prop}`, args: [value] })
|
|
48
|
+
return true
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
recorders.clear()
|
|
55
|
+
realGetContext = HTMLCanvasElement.prototype.getContext
|
|
56
|
+
realGetRect = Element.prototype.getBoundingClientRect
|
|
57
|
+
HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement) {
|
|
58
|
+
return contextFor(this)
|
|
59
|
+
} as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
60
|
+
// jsdom lays everything out at 0×0; the surface needs a size to paint into.
|
|
61
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
62
|
+
return { x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 120, width: 1000, height: 120, toJSON: () => ({}) } as DOMRect
|
|
63
|
+
}
|
|
64
|
+
vi.useFakeTimers()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
cleanup()
|
|
69
|
+
vi.useRealTimers()
|
|
70
|
+
HTMLCanvasElement.prototype.getContext = realGetContext
|
|
71
|
+
Element.prototype.getBoundingClientRect = realGetRect
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const project = {
|
|
75
|
+
id: 'p1',
|
|
76
|
+
tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8 }]],
|
|
77
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 5 }] },
|
|
78
|
+
} as unknown as Project
|
|
79
|
+
|
|
80
|
+
// Stable identity: the content layer's redraw effect keys on the `selectedIds`
|
|
81
|
+
// prop, so a fresh literal on every rerender would repaint the content and
|
|
82
|
+
// make the guide's layer-isolation assertion meaningless. Timeline passes
|
|
83
|
+
// React state here, which has exactly this stability.
|
|
84
|
+
const NO_SELECTION: string[] = []
|
|
85
|
+
|
|
86
|
+
function mount(
|
|
87
|
+
store: ViewportStore,
|
|
88
|
+
clock = createPlaybackClock(),
|
|
89
|
+
onRender?: () => void,
|
|
90
|
+
selectedIds: string[] = NO_SELECTION,
|
|
91
|
+
) {
|
|
92
|
+
function Sibling() { onRender?.(); return null }
|
|
93
|
+
function Surface({ previewAxis = false }: { previewAxis?: boolean }) {
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<TimelineCanvas
|
|
97
|
+
project={project}
|
|
98
|
+
clock={clock}
|
|
99
|
+
store={store}
|
|
100
|
+
totalDuration={20}
|
|
101
|
+
fps={30}
|
|
102
|
+
selectedIds={selectedIds}
|
|
103
|
+
previewAxis={previewAxis}
|
|
104
|
+
/>
|
|
105
|
+
<Sibling />
|
|
106
|
+
</>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
const utils = render(<Surface />)
|
|
110
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
111
|
+
const canvases = utils.container.querySelectorAll('canvas')
|
|
112
|
+
return {
|
|
113
|
+
...utils,
|
|
114
|
+
container: utils.container,
|
|
115
|
+
setAxis: (on: boolean) => utils.rerender(<Surface previewAxis={on} />),
|
|
116
|
+
clock,
|
|
117
|
+
content: recorderFor(canvases[0] as HTMLCanvasElement),
|
|
118
|
+
overlay: recorderFor(canvases[1] as HTMLCanvasElement),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('TimelineCanvas', () => {
|
|
123
|
+
it('paints both layers on mount and sizes the viewport from the surface', () => {
|
|
124
|
+
const store = createViewportStore()
|
|
125
|
+
const { content, overlay } = mount(store)
|
|
126
|
+
|
|
127
|
+
expect(content.some(c => c.method === 'clearRect')).toBe(true)
|
|
128
|
+
expect(overlay.some(c => c.method === 'clearRect')).toBe(true)
|
|
129
|
+
// First layout starts fitted: 20s across 1000px.
|
|
130
|
+
expect(store.get()).toMatchObject({ widthPx: 1000, pxPerSecond: 50, scrollSeconds: 0 })
|
|
131
|
+
// Backing stores follow the CSS box (dpr 1 in jsdom).
|
|
132
|
+
expect(document.querySelector('canvas')!.getAttribute('width')).toBe('1000')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('moves the playhead without repainting the content or re-rendering React', () => {
|
|
136
|
+
const store = createViewportStore()
|
|
137
|
+
const renders = vi.fn()
|
|
138
|
+
const { content, overlay, clock } = mount(store, createPlaybackClock(), renders)
|
|
139
|
+
|
|
140
|
+
const contentCallsBefore = content.length
|
|
141
|
+
const rendersBefore = renders.mock.calls.length
|
|
142
|
+
overlay.length = 0
|
|
143
|
+
|
|
144
|
+
act(() => { clock.set(3) })
|
|
145
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
146
|
+
|
|
147
|
+
expect(overlay.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.playhead)).toBe(true)
|
|
148
|
+
expect(content.length).toBe(contentCallsBefore)
|
|
149
|
+
expect(renders.mock.calls.length).toBe(rendersBefore)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('repaints only the overlay when the axis cursor moves — it shares the playhead layer', () => {
|
|
153
|
+
// Tracking the pointer must cost what moving the playhead costs: one
|
|
154
|
+
// overlay repaint, no content repaint. A cursor line on the content layer
|
|
155
|
+
// would repaint every clip on every mousemove, which is the exact cost this
|
|
156
|
+
// surface exists to avoid — this is the test that would catch it.
|
|
157
|
+
const store = createViewportStore()
|
|
158
|
+
const { content, overlay, container, setAxis } = mount(store)
|
|
159
|
+
act(() => { setAxis(true) })
|
|
160
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
161
|
+
|
|
162
|
+
const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
163
|
+
const contentCallsBefore = content.length
|
|
164
|
+
overlay.length = 0
|
|
165
|
+
|
|
166
|
+
act(() => {
|
|
167
|
+
surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 400, clientY: 20, bubbles: true }))
|
|
168
|
+
})
|
|
169
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
170
|
+
|
|
171
|
+
expect(overlay.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.cursor)).toBe(true)
|
|
172
|
+
expect(overlay.filter(c => c.method === 'clearRect')).toHaveLength(1)
|
|
173
|
+
expect(content.length).toBe(contentCallsBefore)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ── Trim-handle hover: the one part of the affordance that needs a mount ──
|
|
177
|
+
//
|
|
178
|
+
// `drawTrimHandle` is covered as a pure unit in draw.test.ts. What only a
|
|
179
|
+
// mounted surface can show is that resting the pointer on a handle reaches
|
|
180
|
+
// the painter at all — the hit-test, the change-detection and the content
|
|
181
|
+
// repaint are three separate places this can silently not happen.
|
|
182
|
+
|
|
183
|
+
it('lights the handle under the pointer, repainting the content once', () => {
|
|
184
|
+
const store = createViewportStore()
|
|
185
|
+
const { content, container } = mount(store, createPlaybackClock(), undefined, ['c0'])
|
|
186
|
+
const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
187
|
+
content.length = 0
|
|
188
|
+
|
|
189
|
+
// c0 ends at 8s; at 50px/s that is x=400, so x=395 is inside its out handle.
|
|
190
|
+
act(() => {
|
|
191
|
+
surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 395, clientY: 60, bubbles: true }))
|
|
192
|
+
})
|
|
193
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
194
|
+
|
|
195
|
+
expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.handleFillHovered)).toBe(true)
|
|
196
|
+
// The other handle stays at its resting fill — only one is ever lit.
|
|
197
|
+
expect(content.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.handleFill)).toHaveLength(1)
|
|
198
|
+
expect(content.filter(c => c.method === 'clearRect')).toHaveLength(1)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('takes the light off again when the pointer moves onto the clip body', () => {
|
|
202
|
+
const store = createViewportStore()
|
|
203
|
+
const { content, container } = mount(store, createPlaybackClock(), undefined, ['c0'])
|
|
204
|
+
const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
205
|
+
|
|
206
|
+
act(() => { surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 395, clientY: 60, bubbles: true })) })
|
|
207
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
208
|
+
content.length = 0
|
|
209
|
+
|
|
210
|
+
act(() => { surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 200, clientY: 60, bubbles: true })) })
|
|
211
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
212
|
+
|
|
213
|
+
expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.handleFillHovered)).toBe(false)
|
|
214
|
+
expect(content.filter(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.handleFill)).toHaveLength(2)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('costs no content repaint at all when nothing is selected', () => {
|
|
218
|
+
// Handles are drawn on selected items only, so with an empty selection
|
|
219
|
+
// there is nothing to light and hovering must stay as cheap as it was.
|
|
220
|
+
const store = createViewportStore()
|
|
221
|
+
const { content, container } = mount(store)
|
|
222
|
+
const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
223
|
+
const before = content.length
|
|
224
|
+
|
|
225
|
+
act(() => { surface.dispatchEvent(new MouseEvent('mousemove', { clientX: 395, clientY: 60, bubbles: true })) })
|
|
226
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
227
|
+
|
|
228
|
+
expect(content.length).toBe(before)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('never rings itself when the pointer focuses it and the keyboard drives it', () => {
|
|
232
|
+
// The surface is focused by mouse (so Delete/Enter reach Timeline's root
|
|
233
|
+
// guard) and then driven by keyboard — space to play/pause — which is the
|
|
234
|
+
// exact sequence `:focus-visible` exists to catch. Left alone, the first
|
|
235
|
+
// keypress drew a focus ring around every track at once.
|
|
236
|
+
const store = createViewportStore()
|
|
237
|
+
const { container } = mount(store)
|
|
238
|
+
const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
239
|
+
|
|
240
|
+
expect(surface.className).toContain('outline-none')
|
|
241
|
+
// And it stays unreachable by tab, which is what makes suppressing the
|
|
242
|
+
// ring free rather than a keyboard-navigation regression.
|
|
243
|
+
expect(surface.getAttribute('tabindex')).toBe('-1')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('coalesces a burst of clock ticks into a single frame', () => {
|
|
247
|
+
const store = createViewportStore()
|
|
248
|
+
const { overlay, clock } = mount(store)
|
|
249
|
+
overlay.length = 0
|
|
250
|
+
|
|
251
|
+
act(() => { for (let t = 1; t <= 10; t++) clock.set(t) })
|
|
252
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
253
|
+
|
|
254
|
+
expect(overlay.filter(c => c.method === 'clearRect')).toHaveLength(1)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('zooms at the cursor on ⌘/Ctrl-wheel without re-rendering React', () => {
|
|
258
|
+
const store = createViewportStore()
|
|
259
|
+
const renders = vi.fn()
|
|
260
|
+
const { container } = mount(store, createPlaybackClock(), renders)
|
|
261
|
+
const rendersBefore = renders.mock.calls.length
|
|
262
|
+
const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
263
|
+
|
|
264
|
+
const before = store.get()
|
|
265
|
+
act(() => {
|
|
266
|
+
surface.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, ctrlKey: true, clientX: 500, bubbles: true, cancelable: true }))
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
expect(store.get().pxPerSecond).toBeCloseTo(before.pxPerSecond * Math.exp(0.2), 6)
|
|
270
|
+
expect(renders.mock.calls.length).toBe(rendersBefore)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('leaves a plain wheel to the page', () => {
|
|
274
|
+
const store = createViewportStore()
|
|
275
|
+
const { container } = mount(store)
|
|
276
|
+
const surface = container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
277
|
+
const before = store.get()
|
|
278
|
+
|
|
279
|
+
const event = new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })
|
|
280
|
+
act(() => { surface.dispatchEvent(event) })
|
|
281
|
+
|
|
282
|
+
expect(event.defaultPrevented).toBe(false)
|
|
283
|
+
expect(store.get()).toEqual(before)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('repaints the content when the project changes', () => {
|
|
287
|
+
const store = createViewportStore()
|
|
288
|
+
const clock = createPlaybackClock()
|
|
289
|
+
const { rerender, container } = render(
|
|
290
|
+
<TimelineCanvas project={project} clock={clock} store={store} totalDuration={20} fps={30} selectedIds={[]} />,
|
|
291
|
+
)
|
|
292
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
293
|
+
const content = recorderFor(container.querySelectorAll('canvas')[0] as HTMLCanvasElement)
|
|
294
|
+
content.length = 0
|
|
295
|
+
|
|
296
|
+
const edited = { ...project, tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 2, end: 9 }]] } as unknown as Project
|
|
297
|
+
rerender(<TimelineCanvas project={edited} clock={clock} store={store} totalDuration={20} fps={30} selectedIds={[]} />)
|
|
298
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
299
|
+
|
|
300
|
+
expect(content.some(c => c.method === 'clearRect')).toBe(true)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('keeps redrawing after a StrictMode mount/cleanup/remount cycle', () => {
|
|
304
|
+
// Dev-only regression: StrictMode runs mount → cleanup → mount on the
|
|
305
|
+
// same instance. If the unmount cleanup cancels the pending frame
|
|
306
|
+
// without resetting `frameRef`, every redraw after the replay is
|
|
307
|
+
// silently dropped — the canvas never paints again.
|
|
308
|
+
const store = createViewportStore()
|
|
309
|
+
const clock = createPlaybackClock()
|
|
310
|
+
const { rerender, container } = render(
|
|
311
|
+
<StrictMode>
|
|
312
|
+
<TimelineCanvas project={project} clock={clock} store={store} totalDuration={20} fps={30} selectedIds={[]} />
|
|
313
|
+
</StrictMode>,
|
|
314
|
+
)
|
|
315
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
316
|
+
const content = recorderFor(container.querySelectorAll('canvas')[0] as HTMLCanvasElement)
|
|
317
|
+
content.length = 0
|
|
318
|
+
|
|
319
|
+
rerender(
|
|
320
|
+
<StrictMode>
|
|
321
|
+
<TimelineCanvas project={project} clock={clock} store={store} totalDuration={20} fps={30} selectedIds={['c0']} />
|
|
322
|
+
</StrictMode>,
|
|
323
|
+
)
|
|
324
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
325
|
+
|
|
326
|
+
expect(content.some(c => c.method === 'clearRect')).toBe(true)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
describe('TimelineCanvas — theme mode', () => {
|
|
331
|
+
// The canvas coalesces every redraw into one rAF and repaints only the
|
|
332
|
+
// layers marked dirty, so a theme flip that isn't wired into that
|
|
333
|
+
// dirty-marking leaves BOTH layers showing the previous theme's pixels until
|
|
334
|
+
// something unrelated happens to touch them — and on a paused, untouched
|
|
335
|
+
// timeline the overlay layer is touched by nothing at all. These tests are
|
|
336
|
+
// about that wiring, not about the colours (draw.test.ts owns those).
|
|
337
|
+
function mountWithMode(initial: TimelineMode) {
|
|
338
|
+
const store = createViewportStore()
|
|
339
|
+
const clock = createPlaybackClock()
|
|
340
|
+
function Surface({ mode }: { mode: TimelineMode }) {
|
|
341
|
+
return (
|
|
342
|
+
<TimelineCanvas
|
|
343
|
+
project={project}
|
|
344
|
+
clock={clock}
|
|
345
|
+
store={store}
|
|
346
|
+
totalDuration={20}
|
|
347
|
+
fps={30}
|
|
348
|
+
selectedIds={NO_SELECTION}
|
|
349
|
+
mode={mode}
|
|
350
|
+
/>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
const utils = render(<Surface mode={initial} />)
|
|
354
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
355
|
+
const canvases = utils.container.querySelectorAll('canvas')
|
|
356
|
+
return {
|
|
357
|
+
content: recorderFor(canvases[0] as HTMLCanvasElement),
|
|
358
|
+
overlay: recorderFor(canvases[1] as HTMLCanvasElement),
|
|
359
|
+
setMode: (mode: TimelineMode) => {
|
|
360
|
+
utils.rerender(<Surface mode={mode} />)
|
|
361
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
it('repaints BOTH layers with the new palette when the mode flips', () => {
|
|
367
|
+
const { content, overlay, setMode } = mountWithMode('dark')
|
|
368
|
+
|
|
369
|
+
// Baseline: the surface really did come up dark.
|
|
370
|
+
expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(true)
|
|
371
|
+
content.length = 0
|
|
372
|
+
overlay.length = 0
|
|
373
|
+
|
|
374
|
+
setMode('light')
|
|
375
|
+
|
|
376
|
+
// Content: repainted, and repainted LIGHT — not merely repainted.
|
|
377
|
+
expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === LIGHT_TIMELINE_COLORS.rowBackground)).toBe(true)
|
|
378
|
+
expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(false)
|
|
379
|
+
// Overlay: nothing else on this idle surface would ever repaint it, so a
|
|
380
|
+
// fresh clear+playhead pass here is the whole proof that the flip marked
|
|
381
|
+
// it dirty too. (Its one mark, the playhead, is red in both modes by
|
|
382
|
+
// design — see draw.test.ts — so the colour can't be the tell.)
|
|
383
|
+
expect(overlay.some(c => c.method === 'clearRect')).toBe(true)
|
|
384
|
+
expect(overlay.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.playhead)).toBe(true)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('flips back to dark, so the dependency is the mode and not "changed once"', () => {
|
|
388
|
+
const { content, setMode } = mountWithMode('light')
|
|
389
|
+
expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === LIGHT_TIMELINE_COLORS.rowBackground)).toBe(true)
|
|
390
|
+
content.length = 0
|
|
391
|
+
|
|
392
|
+
setMode('dark')
|
|
393
|
+
expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === TIMELINE_COLORS.rowBackground)).toBe(true)
|
|
394
|
+
expect(content.some(c => c.method === 'set:fillStyle' && c.args[0] === LIGHT_TIMELINE_COLORS.rowBackground)).toBe(false)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('does not repaint when a rerender leaves the mode alone', () => {
|
|
398
|
+
// The other half of the contract: a host re-rendering for any other reason
|
|
399
|
+
// must not cost a full two-layer repaint.
|
|
400
|
+
const { content, overlay, setMode } = mountWithMode('dark')
|
|
401
|
+
content.length = 0
|
|
402
|
+
overlay.length = 0
|
|
403
|
+
setMode('dark')
|
|
404
|
+
expect(content).toHaveLength(0)
|
|
405
|
+
expect(overlay).toHaveLength(0)
|
|
406
|
+
})
|
|
407
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The frames/waveform split inside a video clip. Small, but two painters read
|
|
4
|
+
* it independently — if the bands ever overlap or leave a gap, the filmstrip
|
|
5
|
+
* paints over the waveform (or a stripe of clip fill shows through the seam)
|
|
6
|
+
* at every zoom and on every project.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest'
|
|
9
|
+
import { CLIP_BAND_INSET_PX, CLIP_WAVEFORM_FRACTION, clipBands } from '../clip-bands'
|
|
10
|
+
import { BASE_VISUAL_ROW_RENDER_HEIGHT_PX, VISUAL_ROW_RENDER_HEIGHT_PX } from '../../timeline-model'
|
|
11
|
+
import type { Rect } from '../draw'
|
|
12
|
+
|
|
13
|
+
function rect(over: Partial<Rect> = {}): Rect {
|
|
14
|
+
return { x: 0, y: 0, width: 200, height: BASE_VISUAL_ROW_RENDER_HEIGHT_PX, ...over }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('clipBands', () => {
|
|
18
|
+
it('splits a base-track clip evenly between frames and waveform', () => {
|
|
19
|
+
const { frames, waveform } = clipBands(rect())
|
|
20
|
+
expect(frames.height).toBe(59) // (120 - 2 inset) / 2
|
|
21
|
+
expect(waveform.height).toBe(59)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('stacks the waveform directly under the frames, with no seam gap', () => {
|
|
25
|
+
const { frames, waveform } = clipBands(rect())
|
|
26
|
+
expect(waveform.y).toBe(frames.y + frames.height)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps both bands inside the clip, clear of the border the painter strokes', () => {
|
|
30
|
+
const r = rect({ y: 40 })
|
|
31
|
+
const { frames, waveform } = clipBands(r)
|
|
32
|
+
expect(frames.y).toBe(r.y + CLIP_BAND_INSET_PX)
|
|
33
|
+
expect(waveform.y + waveform.height).toBe(r.y + r.height - CLIP_BAND_INSET_PX)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('conserves the full inset height at an odd height, rounding into the frames band', () => {
|
|
37
|
+
// 41 - 2 = 39 inner: the waveform rounds to 20 and frames absorb the
|
|
38
|
+
// remaining 19, rather than both rounding to 20 and overflowing the clip.
|
|
39
|
+
const { frames, waveform } = clipBands(rect({ height: 41 }))
|
|
40
|
+
expect(frames.height + waveform.height).toBe(39)
|
|
41
|
+
expect(waveform.height).toBe(20)
|
|
42
|
+
expect(frames.height).toBe(19)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('spans the clip\'s full width in both bands', () => {
|
|
46
|
+
const r = rect({ x: 17, width: 340 })
|
|
47
|
+
const { frames, waveform } = clipBands(r)
|
|
48
|
+
for (const band of [frames, waveform]) {
|
|
49
|
+
expect(band.x).toBe(r.x)
|
|
50
|
+
expect(band.width).toBe(r.width)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('gives a non-base row the same even split at its shorter height', () => {
|
|
55
|
+
// Overlay rows stayed at the DOM height — they carry no waveform and no
|
|
56
|
+
// filmstrip, so the split is defined there but never drawn into.
|
|
57
|
+
const { frames, waveform } = clipBands(rect({ height: VISUAL_ROW_RENDER_HEIGHT_PX }))
|
|
58
|
+
expect(frames.height).toBe(19) // (40 - 2) / 2
|
|
59
|
+
expect(waveform.height).toBe(19)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('drops the inset rather than inverting it on a rect too short to hold one', () => {
|
|
63
|
+
const { frames, waveform } = clipBands(rect({ height: 3 }))
|
|
64
|
+
expect(frames.y).toBe(0)
|
|
65
|
+
expect(frames.height + waveform.height).toBe(3)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('yields zero-height bands for a zero-height rect, which every painter skips', () => {
|
|
69
|
+
const { frames, waveform } = clipBands(rect({ height: 0 }))
|
|
70
|
+
expect(frames.height).toBe(0)
|
|
71
|
+
expect(waveform.height).toBe(0)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('is an even split — the fraction the two painters agree on', () => {
|
|
75
|
+
expect(CLIP_WAVEFORM_FRACTION).toBe(0.5)
|
|
76
|
+
})
|
|
77
|
+
})
|