@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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilmstripScrubber — a self-contained hover-scrub thumbnail for one source
|
|
3
|
+
* clip, built for the footage-bin media panel (`docs/plans/footage-bin-media-panel.md`,
|
|
4
|
+
* Phase 2). Unlike the timeline's own `FilmstripStore` (`timeline/canvas/filmstrips.ts`),
|
|
5
|
+
* which tiles the VISIBLE portion of a placed clip across the whole timeline
|
|
6
|
+
* viewport, this component draws exactly ONE tile at a time into a single
|
|
7
|
+
* `<canvas>` sized to its container: the poster frame (source time 0) at rest,
|
|
8
|
+
* and whichever tile is nearest the pointer's horizontal fraction while
|
|
9
|
+
* hovering. It reuses that module's pure tile-selection and blit primitives
|
|
10
|
+
* (`flattenTiles`, `nearestTile`, `tileSourceRect`, `centerCropTo`,
|
|
11
|
+
* `drawFilmstripTiles`, `defaultSheetImageLoader`) rather than duplicating them.
|
|
12
|
+
*
|
|
13
|
+
* `getFilmstrip` is proxy-only and tiles the WHOLE source (no time window), so
|
|
14
|
+
* the index is fetched exactly once per `(projectId, proxySrc)` pair — never
|
|
15
|
+
* re-fetched on hover. Decoded sheet images are cached in a ref, keyed by
|
|
16
|
+
* sheet path, so scrubbing across many tiles from the same sheet never
|
|
17
|
+
* re-decodes it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
|
21
|
+
import type { FilmstripIndex, FilmstripSheet, GetFilmstripArgs } from '../types'
|
|
22
|
+
import {
|
|
23
|
+
centerCropTo,
|
|
24
|
+
defaultSheetImageLoader,
|
|
25
|
+
drawFilmstripTiles,
|
|
26
|
+
flattenTiles,
|
|
27
|
+
nearestTile,
|
|
28
|
+
tileSourceRect,
|
|
29
|
+
type FlatTile,
|
|
30
|
+
type LoadedSheetImage,
|
|
31
|
+
} from '../video/timeline/canvas/filmstrips'
|
|
32
|
+
import { Loader } from '../ui/Loader'
|
|
33
|
+
|
|
34
|
+
export interface FilmstripScrubberProps {
|
|
35
|
+
projectId: string
|
|
36
|
+
/** The source's proxySrc — `getFilmstrip` is proxy-only, per the timeline's
|
|
37
|
+
* own filmstrip fetch policy (see `filmstrips.ts`'s module doc). */
|
|
38
|
+
proxySrc: string
|
|
39
|
+
/** Seconds, for mapping a hover fraction to a source-file time. */
|
|
40
|
+
sourceDuration: number
|
|
41
|
+
getFilmstrip: (args: GetFilmstripArgs) => Promise<FilmstripIndex>
|
|
42
|
+
fileUrl: (path: string) => string
|
|
43
|
+
className?: string
|
|
44
|
+
ariaLabel?: string
|
|
45
|
+
/**
|
|
46
|
+
* Whether hovering scrubs. Default `true` (the footage-bin thumbnail). When
|
|
47
|
+
* `false`, no pointer-move handler is attached and only the static poster
|
|
48
|
+
* frame (source time 0) is drawn — for a card that owns its own hover
|
|
49
|
+
* handling and drives the main preview instead.
|
|
50
|
+
*/
|
|
51
|
+
interactive?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* How the tile fills the canvas. Default `'cover'` (center-crop, unchanged for
|
|
54
|
+
* existing callers). `'contain'` letterboxes the whole tile — preserving its
|
|
55
|
+
* aspect ratio, centered, the remaining area left to the container's neutral
|
|
56
|
+
* background — for an aspect-correct poster.
|
|
57
|
+
*/
|
|
58
|
+
fit?: 'cover' | 'contain'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Phase = 'loading' | 'ready' | 'error'
|
|
62
|
+
|
|
63
|
+
/** Clamp `v` into `[lo, hi]`. */
|
|
64
|
+
function clamp(v: number, lo: number, hi: number): number {
|
|
65
|
+
return Math.min(hi, Math.max(lo, v))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function FilmstripScrubber({
|
|
69
|
+
projectId,
|
|
70
|
+
proxySrc,
|
|
71
|
+
sourceDuration,
|
|
72
|
+
getFilmstrip,
|
|
73
|
+
fileUrl,
|
|
74
|
+
className = '',
|
|
75
|
+
ariaLabel = 'Footage preview',
|
|
76
|
+
interactive = true,
|
|
77
|
+
fit = 'cover',
|
|
78
|
+
}: FilmstripScrubberProps) {
|
|
79
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
80
|
+
|
|
81
|
+
// Keep the injected callbacks in refs so the fetch effect below can depend
|
|
82
|
+
// only on `[projectId, proxySrc]` — a fresh closure identity on every host
|
|
83
|
+
// render (common for inline adapter callbacks) must not trigger a re-fetch.
|
|
84
|
+
const getFilmstripRef = useRef(getFilmstrip)
|
|
85
|
+
useEffect(() => { getFilmstripRef.current = getFilmstrip }, [getFilmstrip])
|
|
86
|
+
const fileUrlRef = useRef(fileUrl)
|
|
87
|
+
useEffect(() => { fileUrlRef.current = fileUrl }, [fileUrl])
|
|
88
|
+
|
|
89
|
+
const [phase, setPhase] = useState<Phase>('loading')
|
|
90
|
+
const indexRef = useRef<FilmstripIndex | null>(null)
|
|
91
|
+
const tilesRef = useRef<FlatTile[]>([])
|
|
92
|
+
const imageCacheRef = useRef(new Map<string, LoadedSheetImage>())
|
|
93
|
+
const lastTileRef = useRef<FlatTile | null>(null)
|
|
94
|
+
// Monotonic guard so a slow-resolving image load from an earlier hover
|
|
95
|
+
// position can never paint over a newer one that already loaded/painted.
|
|
96
|
+
const drawRequestIdRef = useRef(0)
|
|
97
|
+
|
|
98
|
+
// ── Fetch the index once per (projectId, proxySrc) ──────────────────────
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
let cancelled = false
|
|
101
|
+
setPhase('loading')
|
|
102
|
+
indexRef.current = null
|
|
103
|
+
tilesRef.current = []
|
|
104
|
+
lastTileRef.current = null
|
|
105
|
+
|
|
106
|
+
getFilmstripRef.current({ projectId, src: proxySrc })
|
|
107
|
+
.then(index => {
|
|
108
|
+
if (cancelled) return
|
|
109
|
+
indexRef.current = index
|
|
110
|
+
tilesRef.current = flattenTiles(index)
|
|
111
|
+
setPhase('ready')
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
if (cancelled) return
|
|
115
|
+
setPhase('error')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return () => { cancelled = true }
|
|
119
|
+
}, [projectId, proxySrc])
|
|
120
|
+
|
|
121
|
+
// ── Canvas backing-store sizing (DPR-aware) ──────────────────────────────
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const canvas = canvasRef.current
|
|
124
|
+
if (!canvas) return
|
|
125
|
+
|
|
126
|
+
const resize = () => {
|
|
127
|
+
const rect = canvas.getBoundingClientRect()
|
|
128
|
+
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1
|
|
129
|
+
const width = Math.max(1, Math.round(rect.width * dpr))
|
|
130
|
+
const height = Math.max(1, Math.round(rect.height * dpr))
|
|
131
|
+
if (canvas.width === width && canvas.height === height) return
|
|
132
|
+
canvas.width = width
|
|
133
|
+
canvas.height = height
|
|
134
|
+
// Writing width/height clears the canvas; repaint whatever was showing.
|
|
135
|
+
if (lastTileRef.current) drawTile(lastTileRef.current)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
resize()
|
|
139
|
+
|
|
140
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
141
|
+
const ro = new ResizeObserver(() => resize())
|
|
142
|
+
ro.observe(canvas)
|
|
143
|
+
return () => ro.disconnect()
|
|
144
|
+
}
|
|
145
|
+
return undefined
|
|
146
|
+
// Runs once: the observer (where available) handles every subsequent
|
|
147
|
+
// layout change itself, independent of `phase`.
|
|
148
|
+
}, [])
|
|
149
|
+
|
|
150
|
+
// ── Draw the poster frame once the index is ready ───────────────────────
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (phase !== 'ready') return
|
|
153
|
+
const tile = nearestTile(tilesRef.current, 0)
|
|
154
|
+
if (tile) drawTile(tile)
|
|
155
|
+
}, [phase])
|
|
156
|
+
|
|
157
|
+
/** Paint one tile into the canvas — center-cropped to fill (`cover`), or
|
|
158
|
+
* letterboxed to preserve aspect (`contain`). */
|
|
159
|
+
function paintTile(sheet: FilmstripSheet, tile: FlatTile, img: LoadedSheetImage) {
|
|
160
|
+
const canvas = canvasRef.current
|
|
161
|
+
if (!canvas) return
|
|
162
|
+
const ctx = canvas.getContext('2d')
|
|
163
|
+
if (!ctx) return
|
|
164
|
+
const destWidth = canvas.width
|
|
165
|
+
const destHeight = canvas.height
|
|
166
|
+
if (destWidth <= 0 || destHeight <= 0) return
|
|
167
|
+
|
|
168
|
+
ctx.clearRect(0, 0, destWidth, destHeight)
|
|
169
|
+
|
|
170
|
+
if (fit === 'contain') {
|
|
171
|
+
// Whole tile, scaled to fit inside the canvas, centered. The remaining
|
|
172
|
+
// area is left cleared (transparent) so the container's neutral surface
|
|
173
|
+
// shows through as letterbox bars.
|
|
174
|
+
const full = tileSourceRect(sheet, tile, img.width, img.height)
|
|
175
|
+
if (full.sw > 0 && full.sh > 0) {
|
|
176
|
+
const srcAspect = full.sw / full.sh
|
|
177
|
+
const destAspect = destWidth / destHeight
|
|
178
|
+
const dw = srcAspect > destAspect ? destWidth : destHeight * srcAspect
|
|
179
|
+
const dh = srcAspect > destAspect ? destWidth / srcAspect : destHeight
|
|
180
|
+
drawFilmstripTiles(ctx, [{
|
|
181
|
+
image: img.image,
|
|
182
|
+
sx: full.sx,
|
|
183
|
+
sy: full.sy,
|
|
184
|
+
sw: full.sw,
|
|
185
|
+
sh: full.sh,
|
|
186
|
+
rect: { x: (destWidth - dw) / 2, y: (destHeight - dh) / 2, width: dw, height: dh },
|
|
187
|
+
}])
|
|
188
|
+
}
|
|
189
|
+
lastTileRef.current = tile
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const src = centerCropTo(
|
|
194
|
+
tileSourceRect(sheet, tile, img.width, img.height),
|
|
195
|
+
destWidth / destHeight,
|
|
196
|
+
)
|
|
197
|
+
drawFilmstripTiles(ctx, [{
|
|
198
|
+
image: img.image,
|
|
199
|
+
sx: src.sx,
|
|
200
|
+
sy: src.sy,
|
|
201
|
+
sw: src.sw,
|
|
202
|
+
sh: src.sh,
|
|
203
|
+
rect: { x: 0, y: 0, width: destWidth, height: destHeight },
|
|
204
|
+
}])
|
|
205
|
+
lastTileRef.current = tile
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Resolve `tile`'s sheet image (cache or load) and paint it. Stale loads
|
|
209
|
+
* (superseded by a later call before they resolve) are dropped. */
|
|
210
|
+
function drawTile(tile: FlatTile) {
|
|
211
|
+
const index = indexRef.current
|
|
212
|
+
if (!index) return
|
|
213
|
+
const sheet = index.sheets[tile.sheetIdx]
|
|
214
|
+
if (!sheet) return
|
|
215
|
+
|
|
216
|
+
const cached = imageCacheRef.current.get(sheet.path)
|
|
217
|
+
if (cached) {
|
|
218
|
+
paintTile(sheet, tile, cached)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const requestId = ++drawRequestIdRef.current
|
|
223
|
+
defaultSheetImageLoader(fileUrlRef.current(sheet.path))
|
|
224
|
+
.then(img => {
|
|
225
|
+
imageCacheRef.current.set(sheet.path, img)
|
|
226
|
+
if (drawRequestIdRef.current !== requestId) return
|
|
227
|
+
paintTile(sheet, tile, img)
|
|
228
|
+
})
|
|
229
|
+
.catch(() => {
|
|
230
|
+
// Quiet failure: the canvas keeps showing whatever it last painted
|
|
231
|
+
// (or the neutral placeholder if nothing has painted yet).
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const posterTile = useMemo(() => nearestTile(tilesRef.current, 0), [phase])
|
|
236
|
+
|
|
237
|
+
function handlePointerMove(e: ReactPointerEvent<HTMLCanvasElement>) {
|
|
238
|
+
if (phase !== 'ready' || tilesRef.current.length === 0) return
|
|
239
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
240
|
+
if (rect.width <= 0) return
|
|
241
|
+
const fraction = clamp((e.clientX - rect.left) / rect.width, 0, 1)
|
|
242
|
+
const sourceT = fraction * sourceDuration
|
|
243
|
+
const tile = nearestTile(tilesRef.current, sourceT)
|
|
244
|
+
if (tile) drawTile(tile)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function handlePointerLeave() {
|
|
248
|
+
if (phase !== 'ready') return
|
|
249
|
+
if (posterTile) drawTile(posterTile)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className={`relative overflow-hidden bg-[var(--editor-surface)] ${className}`}>
|
|
254
|
+
<canvas
|
|
255
|
+
ref={canvasRef}
|
|
256
|
+
role="img"
|
|
257
|
+
aria-label={ariaLabel}
|
|
258
|
+
className="block h-full w-full"
|
|
259
|
+
onPointerMove={interactive ? handlePointerMove : undefined}
|
|
260
|
+
onPointerLeave={interactive ? handlePointerLeave : undefined}
|
|
261
|
+
/>
|
|
262
|
+
{phase === 'loading' && (
|
|
263
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
264
|
+
<Loader size="sm" />
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
{phase === 'error' && (
|
|
268
|
+
<div
|
|
269
|
+
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--editor-surface)] text-[10px] text-[var(--editor-text)]/40"
|
|
270
|
+
role="status"
|
|
271
|
+
>
|
|
272
|
+
No preview
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* FilmstripScrubber — smoke test. jsdom has no 2D canvas and never fires a
|
|
4
|
+
* real `Image` load event, so both are stubbed (same shape as
|
|
5
|
+
* `TimelineCanvas.test.tsx`'s recorder and `ReadOnlySlide.test.tsx`'s
|
|
6
|
+
* ResizeObserver mock). What matters here: the index is fetched exactly once
|
|
7
|
+
* with `{ projectId, src: proxySrc }`, the component renders a canvas without
|
|
8
|
+
* throwing, and a resolved index eventually clears the loading state.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
11
|
+
import { render, waitFor, cleanup, fireEvent } from '@testing-library/react'
|
|
12
|
+
import type { FilmstripIndex } from '../../types'
|
|
13
|
+
import FilmstripScrubber from '../FilmstripScrubber'
|
|
14
|
+
|
|
15
|
+
let realGetContext: typeof HTMLCanvasElement.prototype.getContext
|
|
16
|
+
let realGetRect: typeof Element.prototype.getBoundingClientRect
|
|
17
|
+
let realImage: typeof Image
|
|
18
|
+
|
|
19
|
+
const drawImageCalls: unknown[][] = []
|
|
20
|
+
|
|
21
|
+
class FakeImage {
|
|
22
|
+
onload: (() => void) | null = null
|
|
23
|
+
onerror: (() => void) | null = null
|
|
24
|
+
naturalWidth = 320
|
|
25
|
+
naturalHeight = 320
|
|
26
|
+
width = 320
|
|
27
|
+
height = 320
|
|
28
|
+
private _src = ''
|
|
29
|
+
get src() { return this._src }
|
|
30
|
+
set src(v: string) {
|
|
31
|
+
this._src = v
|
|
32
|
+
// Simulate an async decode, same as a real <img> firing `load` later.
|
|
33
|
+
queueMicrotask(() => this.onload?.())
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
drawImageCalls.length = 0
|
|
39
|
+
|
|
40
|
+
realGetContext = HTMLCanvasElement.prototype.getContext
|
|
41
|
+
HTMLCanvasElement.prototype.getContext = function () {
|
|
42
|
+
return {
|
|
43
|
+
clearRect: () => {},
|
|
44
|
+
drawImage: (...args: unknown[]) => { drawImageCalls.push(args) },
|
|
45
|
+
} as unknown as CanvasRenderingContext2D
|
|
46
|
+
} as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
47
|
+
|
|
48
|
+
realGetRect = Element.prototype.getBoundingClientRect
|
|
49
|
+
Element.prototype.getBoundingClientRect = function () {
|
|
50
|
+
return { x: 0, y: 0, top: 0, left: 0, right: 200, bottom: 120, width: 200, height: 120, toJSON: () => ({}) } as DOMRect
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
realImage = globalThis.Image
|
|
54
|
+
;(globalThis as unknown as { Image: unknown }).Image = FakeImage
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
cleanup()
|
|
59
|
+
vi.restoreAllMocks()
|
|
60
|
+
HTMLCanvasElement.prototype.getContext = realGetContext
|
|
61
|
+
Element.prototype.getBoundingClientRect = realGetRect
|
|
62
|
+
;(globalThis as unknown as { Image: unknown }).Image = realImage
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const fakeIndex: FilmstripIndex = {
|
|
66
|
+
sheets: [{
|
|
67
|
+
path: 'proj1/filmstrips/proxy-0.jpg',
|
|
68
|
+
cols: 2,
|
|
69
|
+
rows: 1,
|
|
70
|
+
tiles: [{ t: 0, row: 0, col: 0 }, { t: 5, row: 0, col: 1 }],
|
|
71
|
+
}],
|
|
72
|
+
interval: 5,
|
|
73
|
+
tileWidth: 160,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('FilmstripScrubber', () => {
|
|
77
|
+
it('fetches the filmstrip index once with { projectId, src: proxySrc } and renders a canvas', async () => {
|
|
78
|
+
const getFilmstrip = vi.fn().mockResolvedValue(fakeIndex)
|
|
79
|
+
const fileUrl = vi.fn((path: string) => `https://cdn.example/${path}`)
|
|
80
|
+
|
|
81
|
+
const { container } = render(
|
|
82
|
+
<FilmstripScrubber
|
|
83
|
+
projectId="proj1"
|
|
84
|
+
proxySrc="proxy.mp4"
|
|
85
|
+
sourceDuration={10}
|
|
86
|
+
getFilmstrip={getFilmstrip}
|
|
87
|
+
fileUrl={fileUrl}
|
|
88
|
+
/>,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect(getFilmstrip).toHaveBeenCalledTimes(1)
|
|
92
|
+
expect(getFilmstrip).toHaveBeenCalledWith({ projectId: 'proj1', src: 'proxy.mp4' })
|
|
93
|
+
|
|
94
|
+
const canvas = container.querySelector('canvas')
|
|
95
|
+
expect(canvas).toBeTruthy()
|
|
96
|
+
|
|
97
|
+
// Poster frame paints once the index resolves and the sheet image decodes.
|
|
98
|
+
await waitFor(() => expect(drawImageCalls.length).toBeGreaterThan(0))
|
|
99
|
+
expect(fileUrl).toHaveBeenCalledWith('proj1/filmstrips/proxy-0.jpg')
|
|
100
|
+
|
|
101
|
+
// The loading placeholder is gone once the index has resolved.
|
|
102
|
+
await waitFor(() => expect(container.querySelector('[role="status"]')).toBeNull())
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does not crash and shows a neutral state when getFilmstrip rejects', async () => {
|
|
106
|
+
const getFilmstrip = vi.fn().mockRejectedValue(new Error('no filmstrip'))
|
|
107
|
+
const fileUrl = vi.fn((path: string) => path)
|
|
108
|
+
|
|
109
|
+
const { container } = render(
|
|
110
|
+
<FilmstripScrubber
|
|
111
|
+
projectId="proj1"
|
|
112
|
+
proxySrc="proxy.mp4"
|
|
113
|
+
sourceDuration={10}
|
|
114
|
+
getFilmstrip={getFilmstrip}
|
|
115
|
+
fileUrl={fileUrl}
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(container.querySelector('canvas')).toBeTruthy()
|
|
120
|
+
await waitFor(() => expect(container.querySelector('[role="status"]')?.textContent).toBe('No preview'))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('re-fetches when proxySrc changes but not on a mere callback-identity change', async () => {
|
|
124
|
+
const getFilmstrip = vi.fn().mockResolvedValue(fakeIndex)
|
|
125
|
+
const fileUrl = (path: string) => path
|
|
126
|
+
|
|
127
|
+
const { rerender } = render(
|
|
128
|
+
<FilmstripScrubber
|
|
129
|
+
projectId="proj1"
|
|
130
|
+
proxySrc="proxy-a.mp4"
|
|
131
|
+
sourceDuration={10}
|
|
132
|
+
getFilmstrip={getFilmstrip}
|
|
133
|
+
fileUrl={fileUrl}
|
|
134
|
+
/>,
|
|
135
|
+
)
|
|
136
|
+
await waitFor(() => expect(getFilmstrip).toHaveBeenCalledTimes(1))
|
|
137
|
+
|
|
138
|
+
// Fresh closure identity, same proxySrc: must NOT trigger a re-fetch.
|
|
139
|
+
const getFilmstrip2 = vi.fn().mockResolvedValue(fakeIndex)
|
|
140
|
+
rerender(
|
|
141
|
+
<FilmstripScrubber
|
|
142
|
+
projectId="proj1"
|
|
143
|
+
proxySrc="proxy-a.mp4"
|
|
144
|
+
sourceDuration={10}
|
|
145
|
+
getFilmstrip={getFilmstrip2}
|
|
146
|
+
fileUrl={fileUrl}
|
|
147
|
+
/>,
|
|
148
|
+
)
|
|
149
|
+
expect(getFilmstrip2).not.toHaveBeenCalled()
|
|
150
|
+
|
|
151
|
+
// A real proxySrc change fetches again (via whichever callback is current).
|
|
152
|
+
rerender(
|
|
153
|
+
<FilmstripScrubber
|
|
154
|
+
projectId="proj1"
|
|
155
|
+
proxySrc="proxy-b.mp4"
|
|
156
|
+
sourceDuration={10}
|
|
157
|
+
getFilmstrip={getFilmstrip2}
|
|
158
|
+
fileUrl={fileUrl}
|
|
159
|
+
/>,
|
|
160
|
+
)
|
|
161
|
+
await waitFor(() => expect(getFilmstrip2).toHaveBeenCalledWith({ projectId: 'proj1', src: 'proxy-b.mp4' }))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('attaches no scrub handler when interactive={false}', async () => {
|
|
165
|
+
const getFilmstrip = vi.fn().mockResolvedValue(fakeIndex)
|
|
166
|
+
const fileUrl = (path: string) => path
|
|
167
|
+
|
|
168
|
+
const { container } = render(
|
|
169
|
+
<FilmstripScrubber
|
|
170
|
+
projectId="proj1"
|
|
171
|
+
proxySrc="proxy.mp4"
|
|
172
|
+
sourceDuration={10}
|
|
173
|
+
getFilmstrip={getFilmstrip}
|
|
174
|
+
fileUrl={fileUrl}
|
|
175
|
+
interactive={false}
|
|
176
|
+
/>,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const canvas = container.querySelector('canvas')!
|
|
180
|
+
// The static poster still paints once the index/sheet resolve.
|
|
181
|
+
await waitFor(() => expect(drawImageCalls.length).toBeGreaterThan(0))
|
|
182
|
+
const afterPoster = drawImageCalls.length
|
|
183
|
+
|
|
184
|
+
// A pointer move must NOT scrub (no handler attached) — no extra draw.
|
|
185
|
+
fireEvent.pointerMove(canvas, { clientX: 150 })
|
|
186
|
+
expect(drawImageCalls.length).toBe(afterPoster)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('letterboxes the poster when fit="contain"', async () => {
|
|
190
|
+
const getFilmstrip = vi.fn().mockResolvedValue(fakeIndex)
|
|
191
|
+
const fileUrl = (path: string) => path
|
|
192
|
+
|
|
193
|
+
render(
|
|
194
|
+
<FilmstripScrubber
|
|
195
|
+
projectId="proj1"
|
|
196
|
+
proxySrc="proxy.mp4"
|
|
197
|
+
sourceDuration={10}
|
|
198
|
+
getFilmstrip={getFilmstrip}
|
|
199
|
+
fileUrl={fileUrl}
|
|
200
|
+
interactive={false}
|
|
201
|
+
fit="contain"
|
|
202
|
+
/>,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
await waitFor(() => expect(drawImageCalls.length).toBeGreaterThan(0))
|
|
206
|
+
const last = drawImageCalls[drawImageCalls.length - 1]
|
|
207
|
+
// Canvas is 200×120 (mocked rect, dpr 1). The poster tile is one column of a
|
|
208
|
+
// 320×320 two-col sheet → a 160×320 portrait rect (aspect 0.5). Contained in
|
|
209
|
+
// the landscape canvas it pillarboxes to a 60×120 box centered at x=70.
|
|
210
|
+
// args = [image, sx, sy, sw, sh, destX, destY, destW, destH].
|
|
211
|
+
expect(last[7]).toBe(60) // dest width
|
|
212
|
+
expect(last[8]).toBe(120) // dest height
|
|
213
|
+
expect(last[5]).toBe(70) // dest x (centered)
|
|
214
|
+
expect(last[6]).toBe(0) // dest y
|
|
215
|
+
})
|
|
216
|
+
})
|