@bycrux/editor 0.11.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pitch-preserving audio time-stretch — the shared primitive behind every
|
|
3
|
+
* variable-rate playback path (the fast-forward shuttle's audible scrub and
|
|
4
|
+
* per-clip speed).
|
|
5
|
+
*
|
|
6
|
+
* Runs on the MAIN thread, NOT in the AudioWorklet: it is called from the
|
|
7
|
+
* WebCodecs `AudioDecoder`'s `output` callback (which fires on main), so the
|
|
8
|
+
* stretch happens once, inline, as the PCM is produced, and the already-
|
|
9
|
+
* stretched buffer is what gets posted into the ring — the same seam
|
|
10
|
+
* `audio-clock.ts` already resamples at. Keeping the DSP here (and out of the
|
|
11
|
+
* worklet, which stays "thin by architecture" — see `audio-worklet-source.ts`)
|
|
12
|
+
* is what lets it be a pure function under vitest instead of untestable code
|
|
13
|
+
* inlined in a string.
|
|
14
|
+
*
|
|
15
|
+
* The algorithm is WSOLA (Waveform-Similarity Overlap-Add). Cut the input into
|
|
16
|
+
* overlapping Hann-windowed frames and overlap-add them back at a DIFFERENT
|
|
17
|
+
* hop: advance the read head slower than the write head and the signal grows
|
|
18
|
+
* longer; faster and it shrinks. Because each frame keeps its own local
|
|
19
|
+
* waveform, the pitch is untouched — this is the whole reason it is not a
|
|
20
|
+
* resample (`resampleInterleaved` in `audio-clock.ts`), which changes length by
|
|
21
|
+
* changing pitch. The "waveform-similarity" part is a small cross-correlation
|
|
22
|
+
* search that nudges each frame a few samples so successive frames stay phase-
|
|
23
|
+
* aligned across their overlap; that is what stops periodic sounds from beating
|
|
24
|
+
* or clicking and is the difference between WSOLA and plain OLA.
|
|
25
|
+
*
|
|
26
|
+
* This is PREVIEW quality: clearly intelligible, cheap enough to run inline in
|
|
27
|
+
* a decode callback. The final render stretches with ffmpeg's `atempo`
|
|
28
|
+
* elsewhere, not this code, so broadcast-grade transient handling is out of
|
|
29
|
+
* scope here on purpose.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// ── Tuning constants ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Analysis/synthesis frame length in samples. ~21ms at 48kHz, ~23ms at 44.1kHz
|
|
36
|
+
* — long enough to hold a couple of periods of a low speech/music fundamental
|
|
37
|
+
* (down to ~90Hz), short enough that the local-stationarity assumption OLA
|
|
38
|
+
* rests on holds. Sample-count fixed (the function isn't handed a rate); the
|
|
39
|
+
* 8% span difference between the two target rates is immaterial.
|
|
40
|
+
*/
|
|
41
|
+
const FRAME = 1024
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Synthesis hop — the fixed output advance per frame. FRAME/2 (50% overlap) is
|
|
45
|
+
* the COLA sweet spot for a periodic Hann window: two half-overlapped Hann
|
|
46
|
+
* windows sum to exactly 1.0, so the overlap-add has unity gain in the interior
|
|
47
|
+
* with no per-sample renormalization needed there. The realized stretch factor
|
|
48
|
+
* is `SYNTH_HOP / analysisHop`.
|
|
49
|
+
*/
|
|
50
|
+
const SYNTH_HOP = FRAME / 2
|
|
51
|
+
|
|
52
|
+
/** Overlap region length (= FRAME − SYNTH_HOP). The span the similarity search matches over. */
|
|
53
|
+
const OVERLAP = FRAME - SYNTH_HOP
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How far, in samples, a frame may be nudged from its ideal read position to
|
|
57
|
+
* find the best waveform continuation. ±128 covers more than one full period of
|
|
58
|
+
* anything above ~190Hz and comfortably locks onto a 440Hz test tone (period
|
|
59
|
+
* ~109 samples), while keeping the correlation search — the hot loop — bounded.
|
|
60
|
+
*/
|
|
61
|
+
const SEARCH = 128
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Upper bound on the stretch factor actually applied. Guards against a
|
|
65
|
+
* pathological allocation (a caller passing 1e9 would otherwise ask for a
|
|
66
|
+
* multi-terabyte output); 32× is far beyond any real variable-rate use, where
|
|
67
|
+
* factors live in ~0.25–4.0. The low end needs no clamp — a tiny factor only
|
|
68
|
+
* ever produces a tiny (or empty) output.
|
|
69
|
+
*/
|
|
70
|
+
const MAX_STRETCH = 32
|
|
71
|
+
|
|
72
|
+
/** Below this accumulated window weight an output sample got no real coverage → force it to silence. */
|
|
73
|
+
const WEIGHT_EPSILON = 1e-6
|
|
74
|
+
|
|
75
|
+
/** Below this candidate-window energy the correlation is meaningless (silence) → score 0, keep offset 0. */
|
|
76
|
+
const ENERGY_EPSILON = 1e-9
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Time-stretch interleaved PCM by `factor` with pitch preserved.
|
|
80
|
+
* factor = output_duration / input_duration:
|
|
81
|
+
* factor > 1 → longer/slower (e.g. 2.0 doubles length, half speed playback)
|
|
82
|
+
* factor < 1 → shorter/faster (e.g. 0.5 halves length, double speed playback)
|
|
83
|
+
* factor === 1 → identity (return input unchanged or a faithful copy)
|
|
84
|
+
* `input` is interleaved (L,R,L,R,... for stereo) Float32 at any sample rate.
|
|
85
|
+
* Returns a new interleaved Float32Array with ~ (input.length * factor) samples.
|
|
86
|
+
*/
|
|
87
|
+
export function timeStretch(input: Float32Array, channels: number, factor: number): Float32Array {
|
|
88
|
+
if (!Number.isFinite(factor) || factor <= 0) {
|
|
89
|
+
throw new Error(`timeStretch: factor must be a finite positive number, got ${factor}`)
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(channels) || channels < 1) {
|
|
92
|
+
throw new Error(`timeStretch: channels must be a positive integer, got ${channels}`)
|
|
93
|
+
}
|
|
94
|
+
if (input.length === 0) return new Float32Array(0)
|
|
95
|
+
if (factor === 1) return input.slice() // faithful copy — never alias the caller's buffer
|
|
96
|
+
|
|
97
|
+
const f = Math.min(factor, MAX_STRETCH)
|
|
98
|
+
const inFrames = Math.floor(input.length / channels)
|
|
99
|
+
if (inFrames === 0) return new Float32Array(0)
|
|
100
|
+
|
|
101
|
+
// Read head advance per frame. `round` keeps the realized factor
|
|
102
|
+
// (SYNTH_HOP/analysisHop) as close to requested as integer hops allow;
|
|
103
|
+
// `max(1, …)` stops a large factor from rounding the hop to 0 (infinite loop).
|
|
104
|
+
const analysisHop = Math.max(1, Math.round(SYNTH_HOP / f))
|
|
105
|
+
const numFrames = Math.max(1, Math.round(inFrames / analysisHop))
|
|
106
|
+
|
|
107
|
+
// The mono mix drives the ONE similarity decision every channel shares, so L
|
|
108
|
+
// and R are nudged by the same offset and the stereo image is preserved.
|
|
109
|
+
const mono = channels === 1 ? input : downmix(input, channels, inFrames)
|
|
110
|
+
const win = hann(FRAME)
|
|
111
|
+
|
|
112
|
+
// Size for the full overlap-add span, then slice to the exact target length.
|
|
113
|
+
// Normalization (below) makes the slice clean — there is no fade at the cut.
|
|
114
|
+
const outFrames = Math.round(inFrames * f)
|
|
115
|
+
const spanFrames = (numFrames - 1) * SYNTH_HOP + FRAME
|
|
116
|
+
const totalFrames = Math.max(spanFrames, outFrames)
|
|
117
|
+
const out = new Float32Array(totalFrames * channels)
|
|
118
|
+
const weight = new Float32Array(totalFrames)
|
|
119
|
+
|
|
120
|
+
// `target` holds the previous frame's natural continuation (from the mono
|
|
121
|
+
// mix): the waveform the next frame's leading OVERLAP samples should match.
|
|
122
|
+
const target = new Float32Array(OVERLAP)
|
|
123
|
+
let hasTarget = false
|
|
124
|
+
|
|
125
|
+
for (let m = 0; m < numFrames; m++) {
|
|
126
|
+
const idealPos = m * analysisHop
|
|
127
|
+
// First frame is placed as-is; each later one is nudged within ±SEARCH to
|
|
128
|
+
// best continue the previous frame's tail across the overlap region.
|
|
129
|
+
const readPos = hasTarget ? idealPos + bestOffset(mono, inFrames, idealPos, target) : idealPos
|
|
130
|
+
const synthPos = m * SYNTH_HOP
|
|
131
|
+
|
|
132
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
133
|
+
for (let i = 0; i < FRAME; i++) {
|
|
134
|
+
const src = readPos + i
|
|
135
|
+
if (src < 0 || src >= inFrames) continue // reads outside the input are silence
|
|
136
|
+
out[(synthPos + i) * channels + ch] += input[src * channels + ch] * win[i]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Window weights are identical for every channel; accumulate once. Out-of-
|
|
140
|
+
// input samples still count (their signal is a real 0), which crossfades the
|
|
141
|
+
// buffer's very edges to silence rather than cutting them hard.
|
|
142
|
+
for (let i = 0; i < FRAME; i++) weight[synthPos + i] += win[i]
|
|
143
|
+
|
|
144
|
+
// The natural continuation of the frame just placed: what a match should
|
|
145
|
+
// look like SYNTH_HOP samples on. Read from the mono mix, zero-padded.
|
|
146
|
+
for (let i = 0; i < OVERLAP; i++) {
|
|
147
|
+
const p = readPos + SYNTH_HOP + i
|
|
148
|
+
target[i] = p >= 0 && p < inFrames ? mono[p] : 0
|
|
149
|
+
}
|
|
150
|
+
hasTarget = true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Normalize by accumulated window weight → unity gain everywhere the windows
|
|
154
|
+
// covered (COLA already gives 1.0 in the interior; this also recovers the
|
|
155
|
+
// first/last samples and absorbs any drift from integer hops). Zero where
|
|
156
|
+
// nothing was written, so silence stays silence and the tail never divides
|
|
157
|
+
// by ~0.
|
|
158
|
+
for (let fr = 0; fr < totalFrames; fr++) {
|
|
159
|
+
const w = weight[fr]
|
|
160
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
161
|
+
out[fr * channels + ch] = w > WEIGHT_EPSILON ? out[fr * channels + ch] / w : 0
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return out.length === outFrames * channels ? out : out.slice(0, outFrames * channels)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Internals ────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/** Periodic Hann window (÷N, not ÷(N−1)) — the form whose 50%-overlap copies sum to exactly 1.0. */
|
|
171
|
+
function hann(n: number): Float32Array {
|
|
172
|
+
const w = new Float32Array(n)
|
|
173
|
+
for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / n))
|
|
174
|
+
return w
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Average all channels into one mono track for the shared similarity decision. */
|
|
178
|
+
function downmix(input: Float32Array, channels: number, inFrames: number): Float32Array {
|
|
179
|
+
const mono = new Float32Array(inFrames)
|
|
180
|
+
for (let fr = 0; fr < inFrames; fr++) {
|
|
181
|
+
let s = 0
|
|
182
|
+
for (let ch = 0; ch < channels; ch++) s += input[fr * channels + ch]
|
|
183
|
+
mono[fr] = s / channels
|
|
184
|
+
}
|
|
185
|
+
return mono
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Offset within ±SEARCH of `idealPos` whose OVERLAP-sample window best continues
|
|
190
|
+
* `target`. Ties (and total silence) resolve to 0, so a silent or aperiodic
|
|
191
|
+
* signal degrades gracefully to plain OLA at the ideal hop.
|
|
192
|
+
*/
|
|
193
|
+
function bestOffset(mono: Float32Array, inFrames: number, idealPos: number, target: Float32Array): number {
|
|
194
|
+
let bestOff = 0
|
|
195
|
+
let bestScore = correlate(mono, inFrames, idealPos, target)
|
|
196
|
+
for (let off = -SEARCH; off <= SEARCH; off++) {
|
|
197
|
+
if (off === 0) continue
|
|
198
|
+
const score = correlate(mono, inFrames, idealPos + off, target)
|
|
199
|
+
if (score > bestScore) {
|
|
200
|
+
bestScore = score
|
|
201
|
+
bestOff = off
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return bestOff
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Cross-correlation of `mono[pos … pos+OVERLAP)` against `target`, normalized by
|
|
209
|
+
* the candidate window's energy. The target's energy is constant across all
|
|
210
|
+
* offsets in one search, so dropping it from the denominator leaves the argmax
|
|
211
|
+
* unchanged while halving the work; normalizing by the candidate energy is what
|
|
212
|
+
* removes the bias toward louder windows. Out-of-input samples read as 0.
|
|
213
|
+
*/
|
|
214
|
+
function correlate(mono: Float32Array, inFrames: number, pos: number, target: Float32Array): number {
|
|
215
|
+
let dot = 0
|
|
216
|
+
let energy = 0
|
|
217
|
+
for (let i = 0; i < OVERLAP; i++) {
|
|
218
|
+
const p = pos + i
|
|
219
|
+
const c = p >= 0 && p < inFrames ? mono[p] : 0
|
|
220
|
+
dot += c * target[i]
|
|
221
|
+
energy += c * c
|
|
222
|
+
}
|
|
223
|
+
return energy <= ENERGY_EPSILON ? 0 : dot / Math.sqrt(energy)
|
|
224
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type {
|
|
|
12
12
|
CaptionSegment,
|
|
13
13
|
Captions,
|
|
14
14
|
VisualItem,
|
|
15
|
+
VisualTrack,
|
|
15
16
|
Asset,
|
|
16
17
|
ImageElement,
|
|
17
18
|
OverlayElement,
|
|
@@ -29,6 +30,8 @@ export type {
|
|
|
29
30
|
OverlayFactory,
|
|
30
31
|
RenderEvent,
|
|
31
32
|
RenderOptions,
|
|
33
|
+
RenderExport,
|
|
34
|
+
SampleFrameOptions,
|
|
32
35
|
RenderStatus,
|
|
33
36
|
RenderPhase,
|
|
34
37
|
CaptionEvent,
|
|
@@ -39,12 +42,27 @@ export type {
|
|
|
39
42
|
GlobalOverlayProp,
|
|
40
43
|
VersionEntry,
|
|
41
44
|
WaveformChunk,
|
|
45
|
+
PeaksData,
|
|
46
|
+
PeaksResolution,
|
|
47
|
+
GetWaveformPeaksArgs,
|
|
48
|
+
FilmstripSheet,
|
|
49
|
+
FilmstripIndex,
|
|
50
|
+
GetFilmstripArgs,
|
|
51
|
+
AnalyzeAudioPolishArgs,
|
|
52
|
+
AudioPolishAnalysis,
|
|
53
|
+
FootageDropPayload,
|
|
54
|
+
PendingDrop,
|
|
55
|
+
TimelineDropPlacement,
|
|
42
56
|
EditorAdapter,
|
|
57
|
+
EditorContext,
|
|
43
58
|
EditorTheme,
|
|
44
59
|
EditorSlots,
|
|
45
60
|
CarouselEditorProps,
|
|
46
61
|
VideoEditorProps,
|
|
47
62
|
} from './types'
|
|
63
|
+
// `FOOTAGE_DND_MIME` is a value (const), not a type — exported separately so
|
|
64
|
+
// hosts can compare against it when reading a drag event's MIME data.
|
|
65
|
+
export { FOOTAGE_DND_MIME } from './types'
|
|
48
66
|
|
|
49
67
|
// ── Video editor pure helpers ─────────────────────────────────────────────────
|
|
50
68
|
export {
|
|
@@ -52,12 +70,61 @@ export {
|
|
|
52
70
|
applyCutToItem,
|
|
53
71
|
collapseGaps,
|
|
54
72
|
splitAtTime,
|
|
73
|
+
rippleDelete,
|
|
74
|
+
rollEdit,
|
|
75
|
+
slipItem,
|
|
76
|
+
slideItem,
|
|
77
|
+
setClipSpeed,
|
|
78
|
+
insertClipAt,
|
|
79
|
+
newClipId,
|
|
55
80
|
} from './video/cuts'
|
|
56
81
|
export type { Cut } from './video/cuts'
|
|
57
82
|
export { getOverlayDesignCanvas } from './video/design-canvas'
|
|
83
|
+
// Track-shape tolerance: `project.tracks` may be on disk as the legacy
|
|
84
|
+
// `VisualItem[][]` or as `VisualTrack[]`. Read through `trackItems`; normalize
|
|
85
|
+
// on open with `normalizeTracks` (same object back when already converged).
|
|
86
|
+
export {
|
|
87
|
+
effectiveItemAudio,
|
|
88
|
+
enabledTrackItems,
|
|
89
|
+
enabledTracks,
|
|
90
|
+
mapTrackItems,
|
|
91
|
+
normalizeTracks,
|
|
92
|
+
trackItems,
|
|
93
|
+
withEnabledItemTracks,
|
|
94
|
+
withItemTracks,
|
|
95
|
+
} from './video/timeline/timeline-model'
|
|
96
|
+
// The one placement rule shared by both new-clip drop entry points (the
|
|
97
|
+
// footage-bin drag today, a filesystem drop in a later task) — public so the
|
|
98
|
+
// host can route either drop through it rather than each reimplementing
|
|
99
|
+
// "land where you dropped it, without stomping existing footage".
|
|
100
|
+
export { placeDroppedClip, resolveDropTrackIndex } from './video/timeline/placement'
|
|
101
|
+
export type { DroppedClipPlacement, PlacedClipResult } from './video/timeline/placement'
|
|
102
|
+
|
|
103
|
+
// ── Image tone (HDR image color mapping) ─────────────────────────────────────
|
|
104
|
+
// The picker component is exported so hosts using `onProvideImageTone` can
|
|
105
|
+
// render the same control (variant="header") in their own chrome.
|
|
106
|
+
export { default as ImageToneMenu } from './video/ImageToneMenu'
|
|
107
|
+
export type { ImageToneMenuProps } from './video/ImageToneMenu'
|
|
108
|
+
|
|
109
|
+
// ── Speed control (slider + preset chips) ────────────────────────────────────
|
|
110
|
+
// Shared by the per-clip inspect modal (host `montaj_assets/ui`) and the
|
|
111
|
+
// track-wide settings popover (TrackSettingsPopover.tsx).
|
|
112
|
+
export { default as SpeedControl } from './video/timeline/SpeedControl'
|
|
113
|
+
export type { SpeedControlProps } from './video/timeline/SpeedControl'
|
|
114
|
+
export { default as VolumeControl } from './video/timeline/VolumeControl'
|
|
115
|
+
export type { VolumeControlProps } from './video/timeline/VolumeControl'
|
|
116
|
+
export { IMAGE_TONES, DEFAULT_IMAGE_TONE } from './video/imageTone'
|
|
117
|
+
export type { ImageTone, ImageToneInfo } from './video/imageTone'
|
|
118
|
+
|
|
119
|
+
// ── SDR tone curves (HDR→SDR export look) ────────────────────────────────────
|
|
120
|
+
// Descriptors + the modal's honesty copy. Exported so a host can label its own
|
|
121
|
+
// chrome with the same curve names it sends as `RenderOptions.sdrCurve`.
|
|
122
|
+
export { SDR_CURVES, DEFAULT_SDR_CURVE, sdrCurveInfo, honestyLine } from './video/sdrCurves'
|
|
123
|
+
export type { SdrCurve, SdrCurveInfo } from './video/sdrCurves'
|
|
124
|
+
export type { PreRenderOptions } from './video/RenderModal'
|
|
58
125
|
|
|
59
126
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
60
|
-
export { defaultMontajTheme, applyTheme } from './theme'
|
|
127
|
+
export { defaultMontajTheme, lightMontajTheme, applyTheme, isLightTheme } from './theme'
|
|
61
128
|
|
|
62
129
|
// ── State ───────────────────────────────────────────────────────────────────
|
|
63
130
|
export { useProjectState } from './state/use-project-state'
|
|
@@ -116,11 +183,20 @@ export type { OverlayPreviewProps } from './preview/OverlayPreview'
|
|
|
116
183
|
export { createPlaybackClock, usePlaybackTime } from './video/playback-clock'
|
|
117
184
|
export type { PlaybackClock } from './video/playback-clock'
|
|
118
185
|
|
|
186
|
+
// ── Source preview (footage-bin → main preview scrub, opt-in) ─────────────────
|
|
187
|
+
// External store a host's footage bin sets to drive a source-scrub overlay on
|
|
188
|
+
// the main preview. Pass the store to both the bin card and VideoEditor's
|
|
189
|
+
// `sourcePreview` prop; absent → the main preview is unchanged. See
|
|
190
|
+
// `video/source-preview.ts`.
|
|
191
|
+
export { createSourcePreviewStore, useSourcePreview } from './video/source-preview'
|
|
192
|
+
export type { SourcePreviewStore, SourcePreviewValue } from './video/source-preview'
|
|
193
|
+
|
|
119
194
|
// ── Video preview ─────────────────────────────────────────────────────────────
|
|
120
195
|
export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
|
|
121
196
|
export { default as CarouselPreview } from './video/preview/CarouselPreview'
|
|
122
197
|
export { default as OverlayItemsLayer } from './video/preview/OverlayItemsLayer'
|
|
123
198
|
export { useVideoPlayback } from './video/preview/useVideoPlayback'
|
|
199
|
+
export { useReportContext, REPORT_INTERVAL_MS } from './video/use-report-context'
|
|
124
200
|
export { useDragOverlay } from './video/preview/useDragOverlay'
|
|
125
201
|
export type { Corner, DragType } from './video/preview/useDragOverlay'
|
|
126
202
|
|
|
@@ -142,3 +218,9 @@ export { default as SlideCanvas, resolveAsset } from './carousel/SlideCanvas'
|
|
|
142
218
|
export { default as OverlayErrorBoundary } from './carousel/OverlayErrorBoundary'
|
|
143
219
|
export { default as ReadOnlySlide } from './carousel/ReadOnlySlide'
|
|
144
220
|
export type { ReadOnlySlideProps } from './carousel/ReadOnlySlide'
|
|
221
|
+
|
|
222
|
+
// ── Footage bin (media panel) ─────────────────────────────────────────────────
|
|
223
|
+
// A host's Footage/B-Roll panel drops this in per source card for the
|
|
224
|
+
// hover-scrub thumbnail (docs/plans/footage-bin-media-panel.md, Phase 2/3).
|
|
225
|
+
export { default as FilmstripScrubber } from './components/FilmstripScrubber'
|
|
226
|
+
export type { FilmstripScrubberProps } from './components/FilmstripScrubber'
|
|
@@ -14,44 +14,22 @@
|
|
|
14
14
|
|
|
15
15
|
import React, { useEffect, useState } from 'react'
|
|
16
16
|
import type { OverlayFactory } from '../types'
|
|
17
|
+
import { Loader } from '../ui/Loader'
|
|
17
18
|
|
|
18
19
|
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
19
20
|
|
|
20
21
|
function DefaultSpinner(): React.ReactElement {
|
|
21
22
|
return (
|
|
22
23
|
<div
|
|
23
|
-
role="status"
|
|
24
|
-
aria-label="Loading overlay"
|
|
25
24
|
style={{
|
|
26
25
|
position: 'absolute',
|
|
27
26
|
inset: 0,
|
|
28
27
|
display: 'flex',
|
|
29
28
|
alignItems: 'center',
|
|
30
29
|
justifyContent: 'center',
|
|
31
|
-
color: 'rgba(255, 255, 255, 0.65)',
|
|
32
30
|
}}
|
|
33
31
|
>
|
|
34
|
-
<
|
|
35
|
-
width="36"
|
|
36
|
-
height="36"
|
|
37
|
-
viewBox="0 0 24 24"
|
|
38
|
-
fill="none"
|
|
39
|
-
stroke="currentColor"
|
|
40
|
-
strokeWidth="2"
|
|
41
|
-
strokeLinecap="round"
|
|
42
|
-
>
|
|
43
|
-
<g>
|
|
44
|
-
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
45
|
-
<animateTransform
|
|
46
|
-
attributeName="transform"
|
|
47
|
-
type="rotate"
|
|
48
|
-
from="0 12 12"
|
|
49
|
-
to="360 12 12"
|
|
50
|
-
dur="0.9s"
|
|
51
|
-
repeatCount="indefinite"
|
|
52
|
-
/>
|
|
53
|
-
</g>
|
|
54
|
-
</svg>
|
|
32
|
+
<Loader size="sm" />
|
|
55
33
|
</div>
|
|
56
34
|
)
|
|
57
35
|
}
|
package/src/schema.ts
CHANGED
|
@@ -30,8 +30,14 @@ export interface AudioTrack {
|
|
|
30
30
|
}
|
|
31
31
|
fadeIn?: number // fade-in duration in seconds (0 = no fade)
|
|
32
32
|
fadeOut?: number // fade-out duration in seconds (0 = no fade)
|
|
33
|
+
// Envelope shape for each fade — see video/timeline/canvas/fade-curve.ts.
|
|
34
|
+
// Absent ⇒ DEFAULT_FADE_CURVE ('exp'), which is also the shape every fade
|
|
35
|
+
// rendered before curves existed, so an un-set project looks unchanged.
|
|
36
|
+
fadeInCurve?: 'linear' | 'log' | 'exp'
|
|
37
|
+
fadeOutCurve?: 'linear' | 'log' | 'exp'
|
|
33
38
|
sourceDuration?: number // intrinsic duration of the source file in seconds
|
|
34
39
|
lane?: number // visual grouping — tracks sharing a lane render in the same row
|
|
40
|
+
magnetic?: boolean // when set, this clip's LANE is kept gapless (no gaps/overlaps). Fanned out across the lane like `muted`.
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export interface CaptionSegment {
|
|
@@ -53,6 +59,24 @@ export interface CaptionSegment {
|
|
|
53
59
|
// ffmpeg `drawtext` render branch has no per-segment concept and keeps
|
|
54
60
|
// reading only the track-level `color`.
|
|
55
61
|
color?: string
|
|
62
|
+
// Vertical row this segment renders in. Captions in different lanes may be
|
|
63
|
+
// simultaneous and all render, each getting its own row in the timeline and
|
|
64
|
+
// preview. Absent ⇒ lane 0, and NOT auto-incremented.
|
|
65
|
+
//
|
|
66
|
+
// This deliberately diverges from `AudioTrack.lane` / `groupAudioLanes`
|
|
67
|
+
// (timeline/timeline-model.ts), which mint a fresh lane for every lane-less
|
|
68
|
+
// track: every caption segment written before lanes existed is lane-less,
|
|
69
|
+
// so applying that same rule here would explode every existing project's
|
|
70
|
+
// captions into one row per caption on first open, instead of the single
|
|
71
|
+
// row they've always rendered as.
|
|
72
|
+
//
|
|
73
|
+
// Invariant: lanes are dense from 0 (no holes). Read and write this field
|
|
74
|
+
// through `video/captionLanes.ts` rather than touching it directly, so that
|
|
75
|
+
// invariant holds everywhere. Lane is z-order only — a higher lane paints
|
|
76
|
+
// on top; it carries no styling of its own. `Captions.style` and the
|
|
77
|
+
// track-level theme fields (color, fontsize, accentColor, …) stay
|
|
78
|
+
// track-global and do not vary per lane.
|
|
79
|
+
lane?: number
|
|
56
80
|
}
|
|
57
81
|
|
|
58
82
|
export interface Captions {
|
|
@@ -67,12 +91,69 @@ export interface Captions {
|
|
|
67
91
|
fontsize?: number
|
|
68
92
|
bgColor?: string
|
|
69
93
|
// Per-style accent color fields, each read by the matching JSX caption template.
|
|
70
|
-
// A single UI control writes whichever one the active style uses (see
|
|
94
|
+
// A single UI control writes whichever one the active style uses (see CaptionListPanel).
|
|
71
95
|
accentColor?: string // active-word/box accent — highlight-box, outline
|
|
72
96
|
highlightColor?: string // active word — karaoke
|
|
73
97
|
activeColor?: string // active word — pop
|
|
74
98
|
backgroundColor?: string// text box background — subtitle
|
|
75
99
|
googleFonts?: string[] // Google Fonts family specs for the caption template (e.g. ["Figtree:wght@700"])
|
|
100
|
+
// Text-styling fields, honoured by the JSX caption templates only (NOT the ffmpeg
|
|
101
|
+
// drawtext render branch). `fontFamily` and `googleFonts` travel together: a family
|
|
102
|
+
// whose font file isn't also fetched via `googleFonts` renders as the fallback face,
|
|
103
|
+
// in both the editor preview and the export.
|
|
104
|
+
fontFamily?: string // CSS font-family stack (e.g. '"Baloo 2", system-ui, sans-serif')
|
|
105
|
+
fontWeight?: number | string // default is per style: clean/karaoke 700, subtitle 600,
|
|
106
|
+
// pop/word-by-word 800, highlight-box/outline 900 — so an
|
|
107
|
+
// existing project with no fontWeight renders unchanged.
|
|
108
|
+
textTransform?: string // 'uppercase' | 'lowercase' | 'capitalize' | 'none'
|
|
109
|
+
letterSpacing?: string // CSS length, e.g. '0.02em'
|
|
110
|
+
lineHeight?: number | string
|
|
111
|
+
textAlign?: string // 'left' | 'center' | 'right'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Easing applied across the segment LEAVING a keyframe. `hold` is step-end:
|
|
115
|
+
* the value is held until the next keyframe's `t`, then jumps. */
|
|
116
|
+
export type EasingName = 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'hold'
|
|
117
|
+
|
|
118
|
+
/** The transform properties that can be keyframed. Which item KINDS support
|
|
119
|
+
* which of them is decided by `canKeyframe` / `canKeyframeProp`
|
|
120
|
+
* (`montaj_assets/editor/src/video/keyframeOps.ts`) — the runtime source of
|
|
121
|
+
* truth, not this type.
|
|
122
|
+
*
|
|
123
|
+
* `keyframes` is NO LONGER overlay-only (SP9d). Overlays, `image` and
|
|
124
|
+
* `video` items all animate their geometry, in the preview and in the
|
|
125
|
+
* export alike: the renderer compiles each curve into a time-varying ffmpeg
|
|
126
|
+
* filter expression (`render/encode-segment.js`, `animatedGeometry`), which
|
|
127
|
+
* is the per-frame hook the ffmpeg path used to lack.
|
|
128
|
+
*
|
|
129
|
+
* OPACITY ANIMATION is what stayed overlay-only, and that one IS a hard
|
|
130
|
+
* render constraint: ffmpeg applies alpha through `colorchannelmixer`,
|
|
131
|
+
* whose `aa` option is a `<double>` and accepts no expression at all, so a
|
|
132
|
+
* clip cannot be faded through that path in any form. Overlays escape it by
|
|
133
|
+
* being captured frame-by-frame in a browser, where opacity is just CSS.
|
|
134
|
+
* See `canKeyframeProp` for the full reasoning and `docs/RENDER.md` for the
|
|
135
|
+
* measured cost of the alternative. */
|
|
136
|
+
export type KeyframeProp = 'offsetX' | 'offsetY' | 'scale' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
|
|
137
|
+
|
|
138
|
+
export interface Keyframe {
|
|
139
|
+
t: number // ITEM-relative seconds — 0 is the item's own `start`, not timeline zero
|
|
140
|
+
value: number
|
|
141
|
+
easing?: EasingName // governs the segment from THIS keyframe to the next; absent = 'linear'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One animated property. `points` must be ascending by `t` with no duplicates:
|
|
145
|
+
* the read path (`sampleTrack`) assumes it and deliberately does not sort, so
|
|
146
|
+
* the editor normalizes on write.
|
|
147
|
+
*
|
|
148
|
+
* These four types intentionally mirror the canonical definitions in
|
|
149
|
+
* `@bycrux/timeline-core` (`src/curves.js`, declared in its `index.d.ts`),
|
|
150
|
+
* which is where the interpolation actually happens for BOTH the preview and
|
|
151
|
+
* the render. They are re-declared rather than imported because this file is
|
|
152
|
+
* deliberately self-contained (see the header). Keep the two in sync — the
|
|
153
|
+
* shapes are structurally compatible by design. */
|
|
154
|
+
export interface KeyframeTrack {
|
|
155
|
+
prop: KeyframeProp
|
|
156
|
+
points: Keyframe[]
|
|
76
157
|
}
|
|
77
158
|
|
|
78
159
|
export interface VisualItem {
|
|
@@ -82,6 +163,7 @@ export interface VisualItem {
|
|
|
82
163
|
start: number
|
|
83
164
|
end: number
|
|
84
165
|
sourceDuration?: number // video type only — used for right-edge drag guard
|
|
166
|
+
sourceCreatedAt?: string // video type only — container recording timestamp (ISO 8601); footage-bin "Date created" sort. Absent when the source carried no creation_time tag.
|
|
85
167
|
inPoint?: number // video type only
|
|
86
168
|
outPoint?: number // video type only
|
|
87
169
|
loop?: boolean // video type only — loop source clip within project window
|
|
@@ -89,10 +171,17 @@ export interface VisualItem {
|
|
|
89
171
|
offsetX?: number
|
|
90
172
|
offsetY?: number
|
|
91
173
|
scale?: number
|
|
174
|
+
scaleX?: number // absent = fall back to `scale` (the resolver does `scaleX ?? scale ?? 1`)
|
|
175
|
+
scaleY?: number // absent = fall back to `scale` (the resolver does `scaleY ?? scale ?? 1`)
|
|
92
176
|
opacity?: number // 0.0–1.0
|
|
93
177
|
fit?: 'cover' | 'contain' | 'fill' // image type only — how the source fills its box. Default 'cover' (AR-preserving fill+crop). 'contain' letterboxes; 'fill' is legacy stretch (no AR).
|
|
94
178
|
volume?: number // video audio level 0.0–2.0, default 1.0 (ignored for images)
|
|
95
179
|
rotation?: number // degrees, clockwise
|
|
180
|
+
/** Which item kinds support this is decided by `canKeyframe`
|
|
181
|
+
* (video/keyframeOps.ts), the runtime source of truth — see `KeyframeProp`
|
|
182
|
+
* above for why it's overlay-only today (a render constraint, not a UI
|
|
183
|
+
* preference). */
|
|
184
|
+
keyframes?: KeyframeTrack[] // animates the transform props over the item's own lifetime. Overlays, images and video clips all honour it; `opacity` animates on overlays ONLY (see KeyframeProp). Absent = a static item, which renders on the unchanged ffmpeg-positioned path.
|
|
96
185
|
opaque?: boolean // legacy boolean kept for old overlay items
|
|
97
186
|
props?: Record<string, unknown> // overlay type only
|
|
98
187
|
googleFonts?: string[] // overlay type only — Google Fonts family specs (e.g. ["Syne:wght@800"])
|
|
@@ -102,7 +191,15 @@ export interface VisualItem {
|
|
|
102
191
|
normalizedSrc?: string // derived per-window normalized cache; render/preview prefer it; src stays original
|
|
103
192
|
/** Source-time (original coords) the normalizedSrc cache starts at; the cache covers [normalizedInPoint, normalizedInPoint + duration]. Absent ⇒ assume it starts at the clip's inPoint (legacy rebase-to-0). */
|
|
104
193
|
normalizedInPoint?: number
|
|
194
|
+
/** Full-source, all-intra 720p H.264+Opus editing proxy for instant-scrub preview (SP3). Never windowed —
|
|
195
|
+
* no `proxyInPoint` — so one proxy can serve every clip sharing a lazy source. Preview-only: render never
|
|
196
|
+
* reads this field. Preview's src precedence, alpha-safe, is
|
|
197
|
+
* `nobg_preview_src ?? proxySrc ?? normalizedSrc ?? src`. Filename carries a look-version tag
|
|
198
|
+
* (`<stem>_proxy_<PROXY_LOOK>.mp4`); bumping `PROXY_LOOK` (e.g. when Montaj Vivid ships) invalidates every
|
|
199
|
+
* existing proxy by construction — the freshness check sees a different filename and regenerates lazily. */
|
|
200
|
+
proxySrc?: string // video type only
|
|
105
201
|
muted?: boolean // video type only — suppress audio in preview and render
|
|
202
|
+
speed?: number // video type only — playback speed, default 1.0, range 0.25–4
|
|
106
203
|
sourceCrop?: { x: number; y: number; w: number; h: number } // video type only — non-destructive crop of the source clip (0–1 fractions)
|
|
107
204
|
sourceWidth?: number // video type only — intrinsic width of the source clip in pixels
|
|
108
205
|
sourceHeight?: number // video type only — intrinsic height of the source clip in pixels
|
|
@@ -141,6 +238,32 @@ export interface VisualItem {
|
|
|
141
238
|
text?: string
|
|
142
239
|
}
|
|
143
240
|
|
|
241
|
+
/**
|
|
242
|
+
* One visual track: its items plus the properties that belong to the TRACK
|
|
243
|
+
* rather than to any one clip. Track ORDER is meaningful — index 0 is the
|
|
244
|
+
* primary footage track, higher indices render on top.
|
|
245
|
+
*
|
|
246
|
+
* `volume`, `muted` and `enabled` are optional and ABSENT by default; a track
|
|
247
|
+
* carrying none of them behaves exactly as it did before tracks became
|
|
248
|
+
* objects, so nothing needs to write a default in.
|
|
249
|
+
*
|
|
250
|
+
* A project may still be on disk in the legacy `VisualItem[][]` shape (a bare
|
|
251
|
+
* array of item arrays, with nowhere to put the fields above). Readers tolerate
|
|
252
|
+
* both shapes by going through `trackItems()` (video/timeline/timeline-model)
|
|
253
|
+
* rather than touching `project.tracks` directly; `normalizeTracks()` converts
|
|
254
|
+
* legacy → this shape when a project is opened.
|
|
255
|
+
*/
|
|
256
|
+
export interface VisualTrack {
|
|
257
|
+
id: string
|
|
258
|
+
items: VisualItem[]
|
|
259
|
+
/** Gain applied to every item's audio on this track. Absent = unity (1.0). */
|
|
260
|
+
volume?: number
|
|
261
|
+
/** Absent or false = audible. */
|
|
262
|
+
muted?: boolean
|
|
263
|
+
/** Absent or true = the track renders. */
|
|
264
|
+
enabled?: boolean
|
|
265
|
+
}
|
|
266
|
+
|
|
144
267
|
export interface Asset {
|
|
145
268
|
id: string
|
|
146
269
|
src: string
|
|
@@ -215,11 +338,31 @@ export interface Slide {
|
|
|
215
338
|
export interface EditorProject {
|
|
216
339
|
id: string
|
|
217
340
|
status: 'pending' | 'storyboard_ready' | 'draft' | 'final'
|
|
218
|
-
settings: {
|
|
341
|
+
settings: {
|
|
342
|
+
resolution: [number, number]
|
|
343
|
+
fps?: number
|
|
344
|
+
brandKit?: string
|
|
345
|
+
normalize?: 'eager' | 'lazy'
|
|
346
|
+
/** Project working color space (e.g. 'sdr_bt709', 'hdr_hlg'). Written by
|
|
347
|
+
* the render pipeline's smart-detect; read here to gate HDR-only UI. */
|
|
348
|
+
colorSpace?: string
|
|
349
|
+
/** Color mapping for overlay images in HDR renders — see video/imageTone.ts.
|
|
350
|
+
* Absent → the render default ('vivid'). No effect on SDR projects. */
|
|
351
|
+
imageTone?: 'vivid' | 'broadcast' | 'punchy' | 'raw'
|
|
352
|
+
/** Which platform's chrome the preview overlays on top of the video — see
|
|
353
|
+
* video/preview/SocialSafeZoneOverlay.tsx. A viewing aid only; never read
|
|
354
|
+
* by render. Absent = no chrome shown (the "None" picker option, and the
|
|
355
|
+
* default for every project until the operator picks a platform). */
|
|
356
|
+
socialPreview?: 'tiktok' | 'youtube' | 'instagram'
|
|
357
|
+
/** Whether dragging the playhead plays audible scrub grains (tape
|
|
358
|
+
* jog-wheel feel) — see engine/scrub-source.ts. Default false (opt-in);
|
|
359
|
+
* an explicit `true` persists the operator's opt-in. */
|
|
360
|
+
audibleScrub?: boolean
|
|
361
|
+
}
|
|
219
362
|
name?: string | null
|
|
220
363
|
editingPrompt?: string
|
|
221
364
|
slides?: Slide[]
|
|
222
|
-
tracks?:
|
|
365
|
+
tracks?: VisualTrack[]
|
|
223
366
|
captions?: Captions
|
|
224
367
|
audio?: { tracks: AudioTrack[] }
|
|
225
368
|
assets?: Asset[]
|