@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,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP5 T7 — canvas filmstrip rendering: the visibility fetch gate, the
|
|
3
|
+
* ported sheet-index fetch-state store, tile selection + source-rect math,
|
|
4
|
+
* and the draw hooks wired into `drawClipRect`/`drawTimelineContent` (draw.ts,
|
|
5
|
+
* composed with T6's waveform). Follows waveforms.test.ts's (T6) conventions
|
|
6
|
+
* throughout.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
9
|
+
import type { VisualItem } from '../../../../schema'
|
|
10
|
+
import type { FilmstripIndex, FilmstripSheet, Project } from '../../../../types'
|
|
11
|
+
import {
|
|
12
|
+
FILMSTRIP_CELL_ASPECT,
|
|
13
|
+
FilmstripStore,
|
|
14
|
+
centerCropTo,
|
|
15
|
+
clipTimeToSourceTime,
|
|
16
|
+
defaultSheetImageLoader,
|
|
17
|
+
drawFilmstripTiles,
|
|
18
|
+
flattenTiles,
|
|
19
|
+
nearestTile,
|
|
20
|
+
tileSourceRect,
|
|
21
|
+
type FilmstripQueryContext,
|
|
22
|
+
type FilmstripTileDraw,
|
|
23
|
+
type LoadedSheetImage,
|
|
24
|
+
type SheetImageLoader,
|
|
25
|
+
} from '../filmstrips'
|
|
26
|
+
import {
|
|
27
|
+
computeTimelineLayout,
|
|
28
|
+
drawTimelineContent,
|
|
29
|
+
type DrawContext,
|
|
30
|
+
type Rect,
|
|
31
|
+
type TimelineScene,
|
|
32
|
+
} from '../draw'
|
|
33
|
+
import { FETCH_RETRY_COOLDOWN_MS, type WaveformColumn } from '../waveforms'
|
|
34
|
+
import { clipBands } from '../clip-bands'
|
|
35
|
+
import { BASE_VISUAL_ROW_RENDER_HEIGHT_PX } from '../../timeline-model'
|
|
36
|
+
import type { Viewport } from '../viewport'
|
|
37
|
+
|
|
38
|
+
// ── Recording context (mirrors waveforms.test.ts's convention) ───────────
|
|
39
|
+
|
|
40
|
+
interface RecordedCall { method: string; args: unknown[] }
|
|
41
|
+
|
|
42
|
+
function recordingContext() {
|
|
43
|
+
const calls: RecordedCall[] = []
|
|
44
|
+
const props: Record<string, unknown> = {}
|
|
45
|
+
const proxy = new Proxy({}, {
|
|
46
|
+
get(_t, prop: string) {
|
|
47
|
+
if (prop in props) return props[prop]
|
|
48
|
+
if (prop === 'createLinearGradient') {
|
|
49
|
+
return (...args: unknown[]) => {
|
|
50
|
+
calls.push({ method: 'createLinearGradient', args })
|
|
51
|
+
return { addColorStop: (...s: unknown[]) => calls.push({ method: 'addColorStop', args: s }) } as unknown as CanvasGradient
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return (...args: unknown[]) => { calls.push({ method: prop, args }) }
|
|
55
|
+
},
|
|
56
|
+
set(_t, prop: string, value: unknown) {
|
|
57
|
+
props[prop] = value
|
|
58
|
+
calls.push({ method: `set:${prop}`, args: [value] })
|
|
59
|
+
return true
|
|
60
|
+
},
|
|
61
|
+
}) as unknown as DrawContext
|
|
62
|
+
return { ctx: proxy, calls, of: (m: string) => calls.filter(c => c.method === m) }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Fixtures ─────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function clip(over: Partial<VisualItem> = {}): VisualItem {
|
|
68
|
+
return { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, ...over }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rect(over: Partial<Rect> = {}): Rect {
|
|
72
|
+
return { x: 0, y: 0, width: 100, height: BASE_VISUAL_ROW_RENDER_HEIGHT_PX, ...over }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function filmstripIndexFixture(over: Partial<FilmstripIndex> = {}): FilmstripIndex {
|
|
76
|
+
return {
|
|
77
|
+
interval: 1,
|
|
78
|
+
tileWidth: 160,
|
|
79
|
+
sheets: [
|
|
80
|
+
{
|
|
81
|
+
path: 'sheet0.jpg',
|
|
82
|
+
cols: 5,
|
|
83
|
+
rows: 2,
|
|
84
|
+
tiles: Array.from({ length: 10 }, (_, i) => ({ t: i, row: Math.floor(i / 5), col: i % 5 })),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
...over,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadedImage(width = 800, height = 320): LoadedSheetImage {
|
|
92
|
+
return { image: {} as CanvasImageSource, width, height }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fakeLoader(image: LoadedSheetImage = loadedImage()): SheetImageLoader {
|
|
96
|
+
return vi.fn().mockResolvedValue(image)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function queryCtx(over: Partial<FilmstripQueryContext> = {}): FilmstripQueryContext {
|
|
100
|
+
return {
|
|
101
|
+
projectId: 'p1',
|
|
102
|
+
getFilmstrip: vi.fn().mockResolvedValue(filmstripIndexFixture()),
|
|
103
|
+
fileUrl: (p: string) => `/files/${p}`,
|
|
104
|
+
viewport: { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 },
|
|
105
|
+
onReady: vi.fn(),
|
|
106
|
+
loader: fakeLoader(),
|
|
107
|
+
...over,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Flush the microtask queue so a mocked fetcher's `.then()`/`.catch()` runs. */
|
|
112
|
+
function flush(): Promise<void> {
|
|
113
|
+
return new Promise(resolve => setTimeout(resolve, 0))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Cell sizing ────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe('FILMSTRIP_CELL_ASPECT', () => {
|
|
119
|
+
it('is square — the 9:16 footage this edits would otherwise draw as slivers', () => {
|
|
120
|
+
expect(FILMSTRIP_CELL_ASPECT).toBe(1)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('centerCropTo', () => {
|
|
125
|
+
it('crops a tall tile top and bottom to fill a square cell', () => {
|
|
126
|
+
// 9:16 source into a 1:1 destination: full width, a centered square of height.
|
|
127
|
+
expect(centerCropTo({ sx: 0, sy: 0, sw: 90, sh: 160 }, 1)).toEqual({ sx: 0, sy: 35, sw: 90, sh: 90 })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('crops a wide tile left and right to fill a square cell', () => {
|
|
131
|
+
expect(centerCropTo({ sx: 0, sy: 0, sw: 160, sh: 90 }, 1)).toEqual({ sx: 35, sy: 0, sw: 90, sh: 90 })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('keeps the tile\'s offset within its sheet, cropping relative to it', () => {
|
|
135
|
+
// The crop must move the sub-rect INSIDE the tile, never back toward the
|
|
136
|
+
// sheet origin, or every cell would show a slice of its neighbour.
|
|
137
|
+
expect(centerCropTo({ sx: 320, sy: 90, sw: 160, sh: 90 }, 1)).toEqual({ sx: 355, sy: 90, sw: 90, sh: 90 })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('leaves an already-matching aspect untouched', () => {
|
|
141
|
+
const src = { sx: 10, sy: 20, sw: 80, sh: 40 }
|
|
142
|
+
expect(centerCropTo(src, 2)).toEqual(src)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns the source unchanged for a degenerate aspect or size, rather than emitting NaNs', () => {
|
|
146
|
+
const src = { sx: 0, sy: 0, sw: 100, sh: 50 }
|
|
147
|
+
expect(centerCropTo(src, 0)).toEqual(src)
|
|
148
|
+
expect(centerCropTo(src, Number.NaN)).toEqual(src)
|
|
149
|
+
expect(centerCropTo({ sx: 0, sy: 0, sw: 0, sh: 0 }, 1)).toEqual({ sx: 0, sy: 0, sw: 0, sh: 0 })
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// ── Source-time alignment ────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe('clipTimeToSourceTime', () => {
|
|
156
|
+
it('anchors the clip\'s own start at source time 0 when inPoint is absent', () => {
|
|
157
|
+
expect(clipTimeToSourceTime(clip({ start: 2 }), 5)).toBe(3)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('anchors at inPoint when present, offsetting 1:1 from there', () => {
|
|
161
|
+
expect(clipTimeToSourceTime(clip({ start: 2, inPoint: 10 }), 5)).toBe(13)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ── Per-clip speed ──
|
|
165
|
+
//
|
|
166
|
+
// A clip at speed S eats S× the source per project second. Without this the
|
|
167
|
+
// frames and the per-clip waveform described the same clip differently: the
|
|
168
|
+
// waveform windows to inPoint..outPoint, which is speed-correct by
|
|
169
|
+
// construction, while the frames walked the source at 1×.
|
|
170
|
+
|
|
171
|
+
it('eats source faster on a sped-up clip', () => {
|
|
172
|
+
// 3s into a 2x clip is 6s into the source.
|
|
173
|
+
expect(clipTimeToSourceTime(clip({ start: 2, inPoint: 10, speed: 2 }), 5)).toBe(16)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('eats source slower on a slowed clip', () => {
|
|
177
|
+
// 3s into a 0.5x clip is only 1.5s of source.
|
|
178
|
+
expect(clipTimeToSourceTime(clip({ start: 2, inPoint: 10, speed: 0.5 }), 5)).toBe(11.5)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('is a strict no-op at speed 1 and at no speed at all', () => {
|
|
182
|
+
expect(clipTimeToSourceTime(clip({ start: 2, inPoint: 10, speed: 1 }), 5))
|
|
183
|
+
.toBe(clipTimeToSourceTime(clip({ start: 2, inPoint: 10 }), 5))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('lands on outPoint at the clip\'s end, which is what makes it agree with the waveform', () => {
|
|
187
|
+
// The invariant the two renderers share: at the last frame of the clip,
|
|
188
|
+
// the frames have consumed exactly the window the waveform drew. A clip
|
|
189
|
+
// slowed to 0.5x runs 4s of timeline off 2s of source.
|
|
190
|
+
const item = clip({ start: 2, end: 6, inPoint: 10, outPoint: 12, speed: 0.5 })
|
|
191
|
+
expect(clipTimeToSourceTime(item, item.end)).toBe(item.outPoint)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// ── Tile selection ───────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
describe('flattenTiles', () => {
|
|
198
|
+
it('flattens multiple sheets in order, tagging each tile with its sheet index', () => {
|
|
199
|
+
const index = filmstripIndexFixture({
|
|
200
|
+
sheets: [
|
|
201
|
+
{ path: 's0.jpg', cols: 2, rows: 1, tiles: [{ t: 0, row: 0, col: 0 }, { t: 1, row: 0, col: 1 }] },
|
|
202
|
+
{ path: 's1.jpg', cols: 2, rows: 1, tiles: [{ t: 2, row: 0, col: 0 }, { t: 3, row: 0, col: 1 }] },
|
|
203
|
+
],
|
|
204
|
+
})
|
|
205
|
+
expect(flattenTiles(index)).toEqual([
|
|
206
|
+
{ t: 0, sheetIdx: 0, row: 0, col: 0 },
|
|
207
|
+
{ t: 1, sheetIdx: 0, row: 0, col: 1 },
|
|
208
|
+
{ t: 2, sheetIdx: 1, row: 0, col: 0 },
|
|
209
|
+
{ t: 3, sheetIdx: 1, row: 0, col: 1 },
|
|
210
|
+
])
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('nearestTile', () => {
|
|
215
|
+
const tiles = flattenTiles(filmstripIndexFixture({
|
|
216
|
+
sheets: [{ path: 's0.jpg', cols: 3, rows: 1, tiles: [{ t: 0, row: 0, col: 0 }, { t: 2, row: 0, col: 1 }, { t: 4, row: 0, col: 2 }] }],
|
|
217
|
+
}))
|
|
218
|
+
|
|
219
|
+
it('returns null for an empty list', () => {
|
|
220
|
+
expect(nearestTile([], 5)).toBeNull()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('matches an exact t', () => {
|
|
224
|
+
expect(nearestTile(tiles, 2)).toMatchObject({ t: 2 })
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('picks the nearer neighbour on either side', () => {
|
|
228
|
+
expect(nearestTile(tiles, 2.9)).toMatchObject({ t: 2 })
|
|
229
|
+
expect(nearestTile(tiles, 3.1)).toMatchObject({ t: 4 })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('ties toward the earlier tile', () => {
|
|
233
|
+
expect(nearestTile(tiles, 3)).toMatchObject({ t: 2 })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('clamps to the end tiles before the first and after the last', () => {
|
|
237
|
+
expect(nearestTile(tiles, -5)).toMatchObject({ t: 0 })
|
|
238
|
+
expect(nearestTile(tiles, 100)).toMatchObject({ t: 4 })
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// ── Source-rect math ─────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
describe('tileSourceRect', () => {
|
|
245
|
+
it('divides the loaded sheet image evenly by the sheet\'s cols/rows', () => {
|
|
246
|
+
const sheet: FilmstripSheet = { path: 's.jpg', cols: 5, rows: 2, tiles: [] }
|
|
247
|
+
expect(tileSourceRect(sheet, { row: 1, col: 3 }, 800, 320)).toEqual({ sx: 480, sy: 160, sw: 160, sh: 160 })
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('uses the loaded image\'s own dimensions, not a nominal tileWidth', () => {
|
|
251
|
+
const sheet: FilmstripSheet = { path: 's.jpg', cols: 4, rows: 1, tiles: [] }
|
|
252
|
+
// A sheet whose actual pixels don't divide evenly by a nominal 160 tileWidth.
|
|
253
|
+
expect(tileSourceRect(sheet, { row: 0, col: 2 }, 601, 150)).toEqual({ sx: 300.5, sy: 0, sw: 150.25, sh: 150 })
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ── Default loader ───────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
describe('defaultSheetImageLoader', () => {
|
|
260
|
+
it('is exported as the store\'s default (injectable) loader', () => {
|
|
261
|
+
expect(typeof defaultSheetImageLoader).toBe('function')
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// ── Draw primitives ──────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
describe('drawFilmstripTiles', () => {
|
|
268
|
+
it('emits one drawImage per tile at its own destination rect', () => {
|
|
269
|
+
const r = recordingContext()
|
|
270
|
+
const tiles: FilmstripTileDraw[] = [
|
|
271
|
+
{ image: {} as CanvasImageSource, sx: 0, sy: 0, sw: 160, sh: 90, rect: { x: 10, y: 5, width: 40, height: 56 } },
|
|
272
|
+
{ image: {} as CanvasImageSource, sx: 160, sy: 0, sw: 160, sh: 90, rect: { x: 50, y: 5, width: 40, height: 56 } },
|
|
273
|
+
]
|
|
274
|
+
drawFilmstripTiles(r.ctx, tiles)
|
|
275
|
+
|
|
276
|
+
const calls = r.of('drawImage')
|
|
277
|
+
expect(calls).toHaveLength(2)
|
|
278
|
+
expect(calls[0].args.slice(5)).toEqual([10, 5, 40, 56])
|
|
279
|
+
expect(calls[1].args.slice(5)).toEqual([50, 5, 40, 56])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('skips a degenerate destination rect (zero width or height)', () => {
|
|
283
|
+
const r = recordingContext()
|
|
284
|
+
drawFilmstripTiles(r.ctx, [
|
|
285
|
+
{ image: {} as CanvasImageSource, sx: 0, sy: 0, sw: 10, sh: 10, rect: { x: 0, y: 0, width: 0, height: 40 } },
|
|
286
|
+
{ image: {} as CanvasImageSource, sx: 0, sy: 0, sw: 10, sh: 10, rect: { x: 0, y: 0, width: 40, height: 0 } },
|
|
287
|
+
])
|
|
288
|
+
expect(r.of('drawImage')).toHaveLength(0)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('FilmstripStore.clipTiles fetch policy', () => {
|
|
293
|
+
const zoomedOut: Viewport = { pxPerSecond: 12, scrollSeconds: 0, widthPx: 1000 }
|
|
294
|
+
const zoomedIn: Viewport = { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 } // visible range [0, 5]
|
|
295
|
+
|
|
296
|
+
it('fetches when zoomed out, where the old 160px/s threshold used to suppress it', () => {
|
|
297
|
+
// The gate is gone on purpose: cells are sized by the frames band now, so
|
|
298
|
+
// zooming out yields fewer full-size frames rather than a row of slivers.
|
|
299
|
+
const store = new FilmstripStore()
|
|
300
|
+
const fetcher = vi.fn().mockResolvedValue(filmstripIndexFixture())
|
|
301
|
+
store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedOut }))
|
|
302
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('still does not fetch when the clip is off the visible range', () => {
|
|
306
|
+
const store = new FilmstripStore()
|
|
307
|
+
const fetcher = vi.fn()
|
|
308
|
+
store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 100, end: 105 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
|
|
309
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('fetches once the clip is visible', () => {
|
|
313
|
+
const store = new FilmstripStore()
|
|
314
|
+
const fetcher = vi.fn().mockResolvedValue(filmstripIndexFixture())
|
|
315
|
+
store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
|
|
316
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('does not fetch for a clip whose frames band has no height', () => {
|
|
320
|
+
const store = new FilmstripStore()
|
|
321
|
+
const fetcher = vi.fn()
|
|
322
|
+
store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 }), rect({ width: 1000, height: 0 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
|
|
323
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('never fetches when the item has no proxySrc', () => {
|
|
327
|
+
const store = new FilmstripStore()
|
|
328
|
+
const fetcher = vi.fn()
|
|
329
|
+
store.clipTiles(clip({ start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
|
|
330
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('never fetches for a non-video item even with a proxySrc set', () => {
|
|
334
|
+
const store = new FilmstripStore()
|
|
335
|
+
const fetcher = vi.fn()
|
|
336
|
+
const imageItem = clip({ type: 'image', proxySrc: 'proxy.mp4', start: 0, end: 5 } as Partial<VisualItem> as VisualItem)
|
|
337
|
+
store.clipTiles(imageItem, rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
|
|
338
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('fires a fetch once a proxySrc arrives (SSE import completion), since the key changes', () => {
|
|
342
|
+
const store = new FilmstripStore()
|
|
343
|
+
const fetcher = vi.fn().mockResolvedValue(filmstripIndexFixture())
|
|
344
|
+
|
|
345
|
+
store.clipTiles(clip({ start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
|
|
346
|
+
expect(fetcher).not.toHaveBeenCalled()
|
|
347
|
+
|
|
348
|
+
store.clipTiles(clip({ start: 0, end: 5, proxySrc: 'proxy.mp4' }), rect({ width: 1000 }), queryCtx({ getFilmstrip: fetcher, viewport: zoomedIn }))
|
|
349
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('stays inert (never fetches, never throws) when the adapter has no getFilmstrip', () => {
|
|
353
|
+
const store = new FilmstripStore()
|
|
354
|
+
expect(() =>
|
|
355
|
+
store.clipTiles(clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 }), rect({ width: 1000 }), queryCtx({ getFilmstrip: undefined, viewport: zoomedIn })),
|
|
356
|
+
).not.toThrow()
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
// ── Sheet-load laziness ────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
describe('FilmstripStore.clipTiles geometry', () => {
|
|
363
|
+
/** Drive the store to the point where its index AND sheet image are both
|
|
364
|
+
* resolved, then return the tiles it lays out for a clip drawn at `pxPerSecond`. */
|
|
365
|
+
async function laidOut(pxPerSecond: number, image?: LoadedSheetImage) {
|
|
366
|
+
const store = new FilmstripStore()
|
|
367
|
+
const item = clip({ proxySrc: 'proxy.mp4', start: 0, end: 10 })
|
|
368
|
+
const viewport: Viewport = { pxPerSecond, scrollSeconds: 0, widthPx: 1000 }
|
|
369
|
+
// The rect the painter would compute: the clip's own on-screen width.
|
|
370
|
+
const r = rect({ width: (item.end - item.start) * pxPerSecond })
|
|
371
|
+
const ctx = () => queryCtx(image ? { viewport, loader: fakeLoader(image) } : { viewport })
|
|
372
|
+
|
|
373
|
+
store.clipTiles(item, r, ctx()) // starts the index fetch
|
|
374
|
+
await flush()
|
|
375
|
+
store.clipTiles(item, r, ctx()) // index ready — starts the sheet fetch
|
|
376
|
+
await flush()
|
|
377
|
+
return { tiles: store.clipTiles(item, r, ctx()) ?? [], rect: r }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
it('lays every tile inside the clip\'s frames band, never over the waveform half', async () => {
|
|
381
|
+
const { tiles, rect: r } = await laidOut(60)
|
|
382
|
+
const band = clipBands(r).frames
|
|
383
|
+
|
|
384
|
+
expect(tiles.length).toBeGreaterThan(0)
|
|
385
|
+
for (const tile of tiles) {
|
|
386
|
+
expect(tile.rect.y).toBe(band.y)
|
|
387
|
+
expect(tile.rect.height).toBe(band.height)
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('sizes each full cell square to the band height, so frames are never stretched', async () => {
|
|
392
|
+
const { tiles, rect: r } = await laidOut(60)
|
|
393
|
+
const band = clipBands(r).frames
|
|
394
|
+
|
|
395
|
+
// The final cell is clipped by the clip's right edge; every other one is a
|
|
396
|
+
// full square.
|
|
397
|
+
for (const tile of tiles.slice(0, -1)) expect(tile.rect.width).toBe(band.height)
|
|
398
|
+
expect(tiles[tiles.length - 1].rect.width).toBeLessThanOrEqual(band.height)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('crops each tile to its cell rather than squeezing a 9:16 frame into it', async () => {
|
|
402
|
+
// A 450x1600 sheet over 5 cols x 2 rows gives 90x800 tiles — vertical
|
|
403
|
+
// footage, the shape this editor actually cuts. Into a square cell the
|
|
404
|
+
// crop must keep the full width and take a centered square of the height.
|
|
405
|
+
const { tiles } = await laidOut(60, loadedImage(450, 1600))
|
|
406
|
+
const first = tiles[0]
|
|
407
|
+
|
|
408
|
+
expect(first.sw).toBe(90) // full tile width, untouched
|
|
409
|
+
expect(first.sh).toBe(90) // 800 cropped down to a square
|
|
410
|
+
expect(first.sy).toBe(355) // centered: (800 - 90) / 2
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('walks further through the source per cell as you zoom out', async () => {
|
|
414
|
+
// The point of sizing cells by the band instead of by the tile interval:
|
|
415
|
+
// the same strip re-derives which frames it shows at every zoom. Zoomed
|
|
416
|
+
// out, each cell spans seconds and lands on a different tile; zoomed in,
|
|
417
|
+
// cells span fractions of a second and several share the nearest tile.
|
|
418
|
+
const outCells = (await laidOut(12)).tiles
|
|
419
|
+
const inCells = (await laidOut(200)).tiles
|
|
420
|
+
const distinct = (tiles: typeof outCells) => new Set(tiles.map(t => `${t.sx},${t.sy}`)).size
|
|
421
|
+
|
|
422
|
+
expect(distinct(outCells)).toBe(outCells.length)
|
|
423
|
+
expect(distinct(inCells)).toBeLessThan(inCells.length)
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
describe('FilmstripStore sheet-load laziness', () => {
|
|
428
|
+
it('only requests the sheet(s) whose tiles are needed for the visible range', async () => {
|
|
429
|
+
const store = new FilmstripStore()
|
|
430
|
+
const index = filmstripIndexFixture({
|
|
431
|
+
interval: 1,
|
|
432
|
+
sheets: [
|
|
433
|
+
{ path: 'sheet0.jpg', cols: 5, rows: 2, tiles: Array.from({ length: 10 }, (_, i) => ({ t: i, row: Math.floor(i / 5), col: i % 5 })) },
|
|
434
|
+
{ path: 'sheet1.jpg', cols: 5, rows: 2, tiles: Array.from({ length: 10 }, (_, i) => ({ t: 10 + i, row: Math.floor(i / 5), col: i % 5 })) },
|
|
435
|
+
],
|
|
436
|
+
})
|
|
437
|
+
const urls: string[] = []
|
|
438
|
+
const loader: SheetImageLoader = vi.fn((url: string) => { urls.push(url); return Promise.resolve(loadedImage()) })
|
|
439
|
+
const item = clip({ start: 0, end: 20, proxySrc: 'proxy.mp4' })
|
|
440
|
+
// Only the first 1000px (5s at 200px/s) of the 20s clip is "on screen" —
|
|
441
|
+
// the shape a `clampRectToSurface`d rect has for a clip scrolled partly
|
|
442
|
+
// off the right edge.
|
|
443
|
+
const onScreenRect = rect({ x: 0, width: 1000, height: 56 })
|
|
444
|
+
const ctx = queryCtx({ getFilmstrip: vi.fn().mockResolvedValue(index), loader, viewport: { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 } })
|
|
445
|
+
|
|
446
|
+
store.clipTiles(item, onScreenRect, ctx)
|
|
447
|
+
await flush()
|
|
448
|
+
store.clipTiles(item, onScreenRect, ctx) // index now ready — this call requests the needed sheet(s)
|
|
449
|
+
|
|
450
|
+
expect(urls.some(u => u.includes('sheet0'))).toBe(true)
|
|
451
|
+
expect(urls.some(u => u.includes('sheet1'))).toBe(false)
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// ── Quiet retry ──────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
describe('FilmstripStore quiet retry', () => {
|
|
458
|
+
it('swallows a rejected index fetch quietly and retries on the next call once cooldown elapses', async () => {
|
|
459
|
+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(0)
|
|
460
|
+
const store = new FilmstripStore()
|
|
461
|
+
const onReady = vi.fn()
|
|
462
|
+
const fetcher = vi.fn()
|
|
463
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
464
|
+
.mockResolvedValueOnce(filmstripIndexFixture())
|
|
465
|
+
const item = clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 })
|
|
466
|
+
const ctx = () => queryCtx({ getFilmstrip: fetcher, onReady, viewport: { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 } })
|
|
467
|
+
|
|
468
|
+
expect(() => store.clipTiles(item, rect({ width: 1000 }), ctx())).not.toThrow()
|
|
469
|
+
await flush()
|
|
470
|
+
expect(onReady).not.toHaveBeenCalled()
|
|
471
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
472
|
+
|
|
473
|
+
nowSpy.mockReturnValue(FETCH_RETRY_COOLDOWN_MS + 1)
|
|
474
|
+
store.clipTiles(item, rect({ width: 1000 }), ctx())
|
|
475
|
+
await flush()
|
|
476
|
+
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
477
|
+
expect(onReady).toHaveBeenCalledTimes(1)
|
|
478
|
+
nowSpy.mockRestore()
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('does not retry a still-cooling-down error on the very next call (paint-rate request storm)', async () => {
|
|
482
|
+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(0)
|
|
483
|
+
const store = new FilmstripStore()
|
|
484
|
+
const onReady = vi.fn()
|
|
485
|
+
const fetcher = vi.fn().mockRejectedValueOnce(new Error('boom'))
|
|
486
|
+
const item = clip({ proxySrc: 'proxy.mp4', start: 0, end: 5 })
|
|
487
|
+
const ctx = () => queryCtx({ getFilmstrip: fetcher, onReady, viewport: { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000 } })
|
|
488
|
+
|
|
489
|
+
store.clipTiles(item, rect({ width: 1000 }), ctx())
|
|
490
|
+
await flush()
|
|
491
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
492
|
+
|
|
493
|
+
// Same mocked Date.now() as the error — still in cooldown, no refetch.
|
|
494
|
+
store.clipTiles(item, rect({ width: 1000 }), ctx())
|
|
495
|
+
await flush()
|
|
496
|
+
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
497
|
+
nowSpy.mockRestore()
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
// ── Draw integration via drawTimelineContent (draw.ts) ────────────────────
|
|
502
|
+
|
|
503
|
+
function project(over: Partial<Project> = {}): Project {
|
|
504
|
+
return { id: 'p1', ...over } as unknown as Project
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function viewport(over: Partial<Viewport> = {}): Viewport {
|
|
508
|
+
return { pxPerSecond: 200, scrollSeconds: 0, widthPx: 1000, ...over }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function scene(over: Partial<TimelineScene> = {}): TimelineScene {
|
|
512
|
+
const p = over.project ?? project()
|
|
513
|
+
return {
|
|
514
|
+
project: p,
|
|
515
|
+
viewport: viewport(),
|
|
516
|
+
layout: computeTimelineLayout(p),
|
|
517
|
+
selectedIds: [],
|
|
518
|
+
surfaceWidth: 1000,
|
|
519
|
+
surfaceHeight: 200,
|
|
520
|
+
...over,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
describe('drawTimelineContent filmstrip + waveform composition', () => {
|
|
525
|
+
it('draws the filmstrip background and the waveform overlay together, in one drawContent hook', () => {
|
|
526
|
+
const p = project({ tracks: [{ id: 'trk-0', items: [clip({ id: 'c0', start: 0, end: 4, proxySrc: 'proxy.mp4' })] }] })
|
|
527
|
+
const r = recordingContext()
|
|
528
|
+
const tiles: FilmstripTileDraw[] = [{ image: {} as CanvasImageSource, sx: 0, sy: 0, sw: 160, sh: 90, rect: { x: 0, y: 0, width: 800, height: 56 } }]
|
|
529
|
+
const columns: WaveformColumn[] = [{ min: -1, max: 1 }, { min: -0.5, max: 0.5 }]
|
|
530
|
+
|
|
531
|
+
drawTimelineContent(r.ctx, scene({
|
|
532
|
+
project: p,
|
|
533
|
+
layout: computeTimelineLayout(p),
|
|
534
|
+
filmstrips: { clipTiles: (item) => (item.id === 'c0' ? tiles : null) },
|
|
535
|
+
waveforms: { clipColumns: (item) => (item.id === 'c0' ? columns : null), audioColumns: () => null },
|
|
536
|
+
}))
|
|
537
|
+
|
|
538
|
+
expect(r.of('drawImage')).toHaveLength(1) // the filmstrip tile
|
|
539
|
+
// fill + border + 2 waveform bars, at minimum.
|
|
540
|
+
expect(r.of('fillRect').length).toBeGreaterThanOrEqual(4)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('degrades to exactly T6\'s original waveform-only draw-call count when no filmstrip provider is set', () => {
|
|
544
|
+
const p = project({ tracks: [{ id: 'trk-0', items: [clip({ id: 'c0', start: 0, end: 4 })] }] })
|
|
545
|
+
const withScene = recordingContext()
|
|
546
|
+
drawTimelineContent(withScene.ctx, scene({
|
|
547
|
+
project: p,
|
|
548
|
+
layout: computeTimelineLayout(p),
|
|
549
|
+
waveforms: { clipColumns: () => null, audioColumns: () => null },
|
|
550
|
+
}))
|
|
551
|
+
const withoutFilmstripsKey = recordingContext()
|
|
552
|
+
drawTimelineContent(withoutFilmstripsKey.ctx, scene({
|
|
553
|
+
project: p,
|
|
554
|
+
layout: computeTimelineLayout(p),
|
|
555
|
+
filmstrips: undefined,
|
|
556
|
+
waveforms: { clipColumns: () => null, audioColumns: () => null },
|
|
557
|
+
}))
|
|
558
|
+
expect(withScene.calls.length).toBe(withoutFilmstripsKey.calls.length)
|
|
559
|
+
expect(withScene.of('drawImage')).toHaveLength(0)
|
|
560
|
+
})
|
|
561
|
+
})
|