@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,919 @@
|
|
|
1
|
+
// Pure timeline math shared by the DOM track-row area (today) and the canvas
|
|
2
|
+
// track-row area (T4 — see Timeline.tsx's `timeline` flag). Anything lifted
|
|
3
|
+
// here is behavior BOTH surfaces must reproduce identically, so it lives
|
|
4
|
+
// outside either render path.
|
|
5
|
+
|
|
6
|
+
import type { AudioTrack, VisualItem, VisualTrack } from '../../schema'
|
|
7
|
+
import type { Project } from '../../types'
|
|
8
|
+
|
|
9
|
+
// ── Row geometry ─────────────────────────────────────────────────────────
|
|
10
|
+
// Named constants for the row-height magic numbers used in cross-row drag
|
|
11
|
+
// math (the canvas pointer machine, Timeline.tsx). Centralized so the canvas
|
|
12
|
+
// painter (T4) draws rows at these heights consistently.
|
|
13
|
+
|
|
14
|
+
/** Vertical travel, in px, that a cross-track drag must cover to move an item
|
|
15
|
+
* one visual track. NOT the rendered row height (see
|
|
16
|
+
* VISUAL_ROW_RENDER_HEIGHT_PX) — it is deliberately shorter than the row so a
|
|
17
|
+
* drag reaches the neighbouring track before the cursor fully leaves the
|
|
18
|
+
* current one. Lifted verbatim from VisualTrackRow's drag math. */
|
|
19
|
+
export const VISUAL_ROW_HEIGHT_PX = 24
|
|
20
|
+
|
|
21
|
+
/** Audio-lane row height in px. Sized to fit the rail's stacked controls: mute,
|
|
22
|
+
* magnet, and the volume gear share one vertical column (TrackGutter's
|
|
23
|
+
* RailCell), and three 14px buttons plus their gaps need ~58px — at the old
|
|
24
|
+
* 40px the third control (the volume gear) was clipped below the fold once the
|
|
25
|
+
* magnet was added. Doubles as the lane-index drag divisor, so drag travel and
|
|
26
|
+
* rendered height stay coincident for audio lanes. */
|
|
27
|
+
export const AUDIO_LANE_HEIGHT_PX = 64
|
|
28
|
+
|
|
29
|
+
/** Rendered height of a non-base visual track row — 40px. Held at that height
|
|
30
|
+
* on purpose: these rows carry overlays, which have no waveform and no
|
|
31
|
+
* filmstrip to show, so the extra height the base track needs would just be
|
|
32
|
+
* empty space here. */
|
|
33
|
+
export const VISUAL_ROW_RENDER_HEIGHT_PX = 40
|
|
34
|
+
|
|
35
|
+
/** Rendered height of the BASE visual track (index 0). No longer the DOM
|
|
36
|
+
* `h-14` its name came from: a video clip splits its height between a
|
|
37
|
+
* filmstrip and a waveform (see `canvas/clip-bands.ts`), and 56px left ~27px
|
|
38
|
+
* for each — too short to read either. */
|
|
39
|
+
export const BASE_VISUAL_ROW_RENDER_HEIGHT_PX = 120
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
/** Vertical gap between rows — the `gap-1` (0.25rem = 4px) on the DOM track
|
|
43
|
+
* list's flex column. */
|
|
44
|
+
export const ROW_GAP_PX = 4
|
|
45
|
+
|
|
46
|
+
/** Caption row height — matches the `h-10` (2.5rem = 40px) Tailwind class the
|
|
47
|
+
* retired DOM caption row (CaptionTrackRow.tsx) used to draw. The only
|
|
48
|
+
* direct reader is `computeTimelineLayout` (canvas/draw.ts), which seeds
|
|
49
|
+
* each `layout.captions`/`resolved.captions` band with it; TrackGutter's
|
|
50
|
+
* rail cells and the hit-tester both take the rectangles from THAT output
|
|
51
|
+
* rather than reading this constant themselves, so every reader agrees on
|
|
52
|
+
* the same rectangles by construction. */
|
|
53
|
+
export const CAPTION_ROW_HEIGHT_PX = 40
|
|
54
|
+
|
|
55
|
+
// ── Audio lane grouping ──────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface AudioLane {
|
|
58
|
+
laneIndex: number
|
|
59
|
+
tracks: AudioTrack[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Span given to an audio track that declares no `end` and whose source length
|
|
63
|
+
* the project does not record: enough bar to see and grab, nothing more. */
|
|
64
|
+
export const AUDIO_FALLBACK_SPAN_SECONDS = 5
|
|
65
|
+
|
|
66
|
+
/** The value when it is a real, finite number; `null` otherwise. */
|
|
67
|
+
function finiteNumber(value: unknown): number | null {
|
|
68
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The timeline window an audio track occupies.
|
|
73
|
+
*
|
|
74
|
+
* NOT to be confused with `@bycrux/timeline-core`'s `audioWindow(track, t)`,
|
|
75
|
+
* which takes a PLAYHEAD and answers "is this track audible right now, and at
|
|
76
|
+
* what gain". This one takes the project's content duration and answers "where
|
|
77
|
+
* does this bar sit on the timeline". Both are imported into this package, so
|
|
78
|
+
* the names are kept distinct on purpose.
|
|
79
|
+
*
|
|
80
|
+
* `start` and `end` are OPTIONAL on an audio track. `docs/schemas/project.md`
|
|
81
|
+
* marks only `src` required, and the renderer agrees: `render/mix-audio.js`
|
|
82
|
+
* delays by `start ?? 0` and never trims on `end` (it reads `end` only to
|
|
83
|
+
* place a fade-out), so the source window is `inPoint`/`outPoint` alone and a
|
|
84
|
+
* music bed with neither field plays its natural length. The `AudioTrack`
|
|
85
|
+
* type here declares both required — a convenience for the many call sites
|
|
86
|
+
* that do arithmetic on them, not a claim about what is on disk.
|
|
87
|
+
*
|
|
88
|
+
* That gap produced a real defect: a track written without `start`/`end`
|
|
89
|
+
* computed `NaN` for its left and width, so the DOM lane drew an invisible
|
|
90
|
+
* bar and the canvas painter culled it entirely, while the export was
|
|
91
|
+
* correct. Resolving the window here — and only here, at the one funnel every
|
|
92
|
+
* audio surface reads lanes from — is what keeps painter, hit-test and the
|
|
93
|
+
* pointer machine agreeing on where a bar is.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveAudioWindow(
|
|
96
|
+
track: AudioTrack,
|
|
97
|
+
contentDuration = 0,
|
|
98
|
+
): { start: number; end: number } {
|
|
99
|
+
const start = finiteNumber(track.start) ?? 0
|
|
100
|
+
// `> start`, not merely present: a declared window of zero or negative width
|
|
101
|
+
// is the sibling of the missing-window bug and fails the same way. `start: 0,
|
|
102
|
+
// end: 0` is the exact shape `skills/lyrics-video` told agents to write until
|
|
103
|
+
// recently, so projects carrying it exist; `engine/validate.py` rejects it now,
|
|
104
|
+
// but validation does not run on project open, so the editor still has to cope.
|
|
105
|
+
// Treat it as undeclared and fall through to the natural-length chain below.
|
|
106
|
+
const declaredEnd = finiteNumber(track.end)
|
|
107
|
+
if (declaredEnd !== null && declaredEnd > start) return { start, end: declaredEnd }
|
|
108
|
+
|
|
109
|
+
// No `end`: the track plays its natural length. Prefer the source's own
|
|
110
|
+
// length when the project records it, then the rest of the project's
|
|
111
|
+
// content, then a fixed span — so the bar is never zero-width.
|
|
112
|
+
// `outPoint` wins over `sourceDuration` only when it is actually past the
|
|
113
|
+
// in-point. A stale `outPoint: 0` is finite, so a bare `??` would let it beat
|
|
114
|
+
// a perfectly good `sourceDuration` and collapse `natural` to zero.
|
|
115
|
+
const inPt = finiteNumber(track.inPoint) ?? 0
|
|
116
|
+
const outPt = finiteNumber(track.outPoint)
|
|
117
|
+
const sourceEnd = outPt !== null && outPt > inPt ? outPt : finiteNumber(track.sourceDuration)
|
|
118
|
+
const natural = sourceEnd === null ? null : sourceEnd - inPt
|
|
119
|
+
if (natural !== null && natural > 0) return { start, end: start + natural }
|
|
120
|
+
// Guard the horizon too. `contentDuration` comes from `computeDerivedTiming`,
|
|
121
|
+
// which reduces with `Math.max(m, i.end ?? 0)` — so ONE item anywhere in the
|
|
122
|
+
// project with a non-numeric `end` makes it `NaN`, and an unguarded `Math.max`
|
|
123
|
+
// would propagate that straight back into the invisible bar this function
|
|
124
|
+
// exists to prevent. Falling back to 0 yields the fixed span instead.
|
|
125
|
+
const horizon = finiteNumber(contentDuration) ?? 0
|
|
126
|
+
return { start, end: Math.max(horizon, start + AUDIO_FALLBACK_SPAN_SECONDS) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Group audio tracks into rendered lanes, in ascending lane order. Tracks
|
|
131
|
+
* carrying an explicit `lane` keep it; the rest are auto-assigned lanes above
|
|
132
|
+
* the highest explicit one, in array order. Lifted out of Timeline's inline
|
|
133
|
+
* IIFE so the DOM rows and the canvas painter can't drift on which track lands
|
|
134
|
+
* in which row.
|
|
135
|
+
*
|
|
136
|
+
* Also resolves each track's timeline window (see `resolveAudioWindow`). A track
|
|
137
|
+
* whose `start` and `end` are both already finite is returned as the SAME
|
|
138
|
+
* object — no copy, no change — so nothing about a well-formed project is
|
|
139
|
+
* different from before this existed. Only a track missing one of them gets a
|
|
140
|
+
* resolved copy, and edits are applied by `id` (`updateAudioTrack`) rather
|
|
141
|
+
* than by object identity, so a drag or trim on that copy commits back to the
|
|
142
|
+
* right track and writes it a concrete window in passing.
|
|
143
|
+
*/
|
|
144
|
+
export function groupAudioLanes(tracks: AudioTrack[], contentDuration = 0): AudioLane[] {
|
|
145
|
+
const laneMap = new Map<number, AudioTrack[]>()
|
|
146
|
+
let nextAutoLane = 0
|
|
147
|
+
for (const t of tracks) {
|
|
148
|
+
if (t.lane != null && t.lane >= nextAutoLane) nextAutoLane = t.lane + 1
|
|
149
|
+
}
|
|
150
|
+
for (const t of tracks) {
|
|
151
|
+
const lane = t.lane ?? nextAutoLane++
|
|
152
|
+
if (!laneMap.has(lane)) laneMap.set(lane, [])
|
|
153
|
+
const { start, end } = resolveAudioWindow(t, contentDuration)
|
|
154
|
+
laneMap.get(lane)!.push(start === t.start && end === t.end ? t : { ...t, start, end })
|
|
155
|
+
}
|
|
156
|
+
return [...laneMap.entries()]
|
|
157
|
+
.sort((a, b) => a[0] - b[0])
|
|
158
|
+
.map(([laneIndex, laneTracks]) => ({ laneIndex, tracks: laneTracks }))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Item labels ──────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/** Longest run of an overlay's own text kept in its label; past this it stops
|
|
164
|
+
* being scannable and starts being a smear. */
|
|
165
|
+
const LABEL_TEXT_MAX = 28
|
|
166
|
+
|
|
167
|
+
/** Basename without extension, for either separator. */
|
|
168
|
+
function stem(path: string): string {
|
|
169
|
+
const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
170
|
+
const name = cut === -1 ? path : path.slice(cut + 1)
|
|
171
|
+
const dot = name.lastIndexOf('.')
|
|
172
|
+
return dot > 0 ? name.slice(0, dot) : name
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The first human-readable string an overlay's props carry, if any. Overlay
|
|
177
|
+
* components are free-form, so this checks the handful of prop names the shipped
|
|
178
|
+
* ones actually use for their visible copy, in the order a reader would care
|
|
179
|
+
* about: the text of the first line, then a caption, then generic single-string
|
|
180
|
+
* fields.
|
|
181
|
+
*/
|
|
182
|
+
function overlayText(props: Record<string, unknown> | undefined): string | null {
|
|
183
|
+
if (!props) return null
|
|
184
|
+
const lines = props.lines
|
|
185
|
+
if (Array.isArray(lines) && lines.length > 0) {
|
|
186
|
+
const first = lines[0]
|
|
187
|
+
if (first && typeof first === 'object' && typeof (first as { text?: unknown }).text === 'string') {
|
|
188
|
+
const text = (first as { text: string }).text.trim()
|
|
189
|
+
if (text) return text
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const key of ['caption', 'text', 'title', 'label', 'headline'] as const) {
|
|
193
|
+
const value = props[key]
|
|
194
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* What a timeline block says it is.
|
|
201
|
+
*
|
|
202
|
+
* Every overlay used to read `▪ overlay`, which on a project carrying twenty of
|
|
203
|
+
* them identified nothing — you had to click each one to find out which was
|
|
204
|
+
* which. An overlay's component file names its KIND (`text_line`, `photo_hero`)
|
|
205
|
+
* and its props usually carry the copy actually on screen, so the two together
|
|
206
|
+
* say what the block is at a glance:
|
|
207
|
+
*
|
|
208
|
+
* `text_line · we still need them`
|
|
209
|
+
* `photo_hero · ex-googler`
|
|
210
|
+
* `cold_open` (no text of its own)
|
|
211
|
+
*
|
|
212
|
+
* Video clips get NO label: the track rail already says the row is video, and
|
|
213
|
+
* the filmstrip inside the clip identifies which shot it is far better than a
|
|
214
|
+
* word would. Images fall back to their own filename.
|
|
215
|
+
*/
|
|
216
|
+
export function visualItemLabel(item: VisualItem): string {
|
|
217
|
+
if (item.type === 'video') return ''
|
|
218
|
+
if (item.type === 'image') return item.src ? stem(item.src) : 'image'
|
|
219
|
+
|
|
220
|
+
const kind = item.src ? stem(item.src) : 'overlay'
|
|
221
|
+
const text = overlayText(item.props)
|
|
222
|
+
if (!text) return kind
|
|
223
|
+
const trimmed = text.length > LABEL_TEXT_MAX ? `${text.slice(0, LABEL_TEXT_MAX - 1)}…` : text
|
|
224
|
+
return `${kind} · ${trimmed}`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Cross-track move ─────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
export interface CrossTrackMoveArgs {
|
|
230
|
+
/** The project's visual tracks as they stand mid-drag. */
|
|
231
|
+
tracks: VisualTrack[]
|
|
232
|
+
/** The item being dragged, at its ORIGINAL props — only start/end change. */
|
|
233
|
+
item: VisualItem
|
|
234
|
+
/** The dragged item's new timeline window. */
|
|
235
|
+
start: number
|
|
236
|
+
end: number
|
|
237
|
+
/** Which track the drag began on. */
|
|
238
|
+
sourceTrackIdx: number
|
|
239
|
+
/** Vertical travel of the drag in raw pixels (positive = downward). */
|
|
240
|
+
dy: number
|
|
241
|
+
/** Magnet/ripple mode (`ctx.rippleMode`). When true, a colliding target
|
|
242
|
+
* track is no longer disqualifying — the drop RIPPLE-INSERTS on it instead
|
|
243
|
+
* of fanning out to a different track. See the "ripple-insert" section of
|
|
244
|
+
* this function's own doc comment. Defaults to false, which reproduces the
|
|
245
|
+
* function's pre-existing (magnet-off) behaviour byte-for-byte. */
|
|
246
|
+
makeSpace?: boolean
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface ResolveTargetTrackOptions {
|
|
250
|
+
/** Vet candidate tracks on KIND alone, ignoring collisions entirely.
|
|
251
|
+
*
|
|
252
|
+
* For the MOVE itself this is always off — a collision is what makes a drop
|
|
253
|
+
* fan out to a free row in the first place. It exists for callers that need
|
|
254
|
+
* a stable answer to "which row is this drag on" while the item is still
|
|
255
|
+
* sliding, i.e. the canvas timeline's snap tier.
|
|
256
|
+
*
|
|
257
|
+
* The two rejections in the search differ in kind, and only one of them is
|
|
258
|
+
* safe to consult mid-slide:
|
|
259
|
+
*
|
|
260
|
+
* - The kind gate is STRUCTURAL. An overlay can never land on the video
|
|
261
|
+
* track under it, at any horizontal position, for the whole gesture.
|
|
262
|
+
* - A collision is TRANSIENT — it depends on where the item happens to be
|
|
263
|
+
* right now, and it is at its worst precisely where a magnet is trying to
|
|
264
|
+
* pull the item INTO alignment with the very neighbour it overlaps. A
|
|
265
|
+
* tier that consulted it would go dead exactly when it is wanted, and
|
|
266
|
+
* would re-rank (and visibly lurch) every time the clip slid across a
|
|
267
|
+
* neighbour. */
|
|
268
|
+
kindOnly?: boolean
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* WHICH track a cross-track move lands on — the outward search `moveItemAcross
|
|
273
|
+
* Tracks` performs, and nothing else. Extracted from it (it now calls this) so
|
|
274
|
+
* a caller can ask the question WITHOUT performing the move.
|
|
275
|
+
*
|
|
276
|
+
* That matters because the pointed-at index (`sourceTrackIdx - dy/24`) is NOT
|
|
277
|
+
* the landing track: the search rejects it for a kind mismatch or a collision
|
|
278
|
+
* and fans out to a neighbour. Anything that needs to reason about the row the
|
|
279
|
+
* dragged item is currently sitting ON — the canvas timeline's strong snap tier
|
|
280
|
+
* does — has to run this same search, or it ends up reasoning about a row the
|
|
281
|
+
* item was never placed on. See `applyMove` in `canvas/pointer-machine.ts`, and
|
|
282
|
+
* `kindOnly` above for the half of the search that caller deliberately skips.
|
|
283
|
+
*
|
|
284
|
+
* The answer may be past the end of `tracks`, which is the caller's signal that
|
|
285
|
+
* the drop would MINT a new track — a legal landing with, by construction, no
|
|
286
|
+
* neighbours on it.
|
|
287
|
+
*/
|
|
288
|
+
export function resolveTargetTrackIdx(
|
|
289
|
+
{ tracks, item, start, end, sourceTrackIdx, dy, makeSpace = false }: CrossTrackMoveArgs,
|
|
290
|
+
{ kindOnly = false }: ResolveTargetTrackOptions = {},
|
|
291
|
+
): number {
|
|
292
|
+
const trackDelta = Math.round(dy / VISUAL_ROW_HEIGHT_PX)
|
|
293
|
+
const targetIdx = Math.max(0, sourceTrackIdx - trackDelta)
|
|
294
|
+
const duration = end - start
|
|
295
|
+
const overlapMin = duration * 0.3
|
|
296
|
+
|
|
297
|
+
function hasOverlap(items: VisualItem[]): boolean {
|
|
298
|
+
return items.some(other => {
|
|
299
|
+
if (other.id === item.id) return false
|
|
300
|
+
return Math.min(end, other.end) - Math.max(start, other.start) > overlapMin
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Coarse kind gate: video and overlay/image tracks are different worlds
|
|
305
|
+
// (an overlay is composited on top of the video underneath it, not spliced
|
|
306
|
+
// into its timeline), so a candidate track is only valid if it's either
|
|
307
|
+
// empty or already carries the dragged item's own coarse kind. This is
|
|
308
|
+
// deliberately narrow — just "don't let a video item land on an overlay
|
|
309
|
+
// track or vice versa" — NOT the fuller "video tracks form their own block
|
|
310
|
+
// below overlays" reorganization, which is separate follow-up work.
|
|
311
|
+
const coarse = (type?: string) => (type === 'video' ? 'video' : 'overlay')
|
|
312
|
+
const itemKind = coarse(item.type)
|
|
313
|
+
const kindOk = (items: VisualItem[]) => {
|
|
314
|
+
const others = items.filter(o => o.id !== item.id)
|
|
315
|
+
// Every OTHER item, not just the first — a track is allowed to hold both
|
|
316
|
+
// kinds (see TrackGutter's own note on a track that "also holds a
|
|
317
|
+
// clip"), and sampling only `others[0]` would make the check depend on
|
|
318
|
+
// array order rather than actually vetting every item already there.
|
|
319
|
+
return others.length === 0 || others.every(o => coarse(o.type) === itemKind)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let bestIdx = targetIdx
|
|
323
|
+
outer: for (let delta = 0; delta <= tracks.length; delta++) {
|
|
324
|
+
for (const i of delta === 0 ? [targetIdx] : [targetIdx - delta, targetIdx + delta]) {
|
|
325
|
+
if (i < 0) continue
|
|
326
|
+
// Past the end of the array is a new track — it inherits the dragged
|
|
327
|
+
// item's own kind by construction, so `[]` always passes `kindOk`.
|
|
328
|
+
const candidateItems = i < tracks.length ? tracks[i].items : []
|
|
329
|
+
// In ripple mode a collision is no longer disqualifying (it becomes a
|
|
330
|
+
// ripple-insert in `moveItemAcrossTracks`), so the gate drops to
|
|
331
|
+
// kind-only — which is what keeps the search from fanning out past the
|
|
332
|
+
// pointed-at track just because something is sitting there. `kindOnly`
|
|
333
|
+
// drops it for a different reason; see the option's own doc.
|
|
334
|
+
const collisionOk = kindOnly || makeSpace || !hasOverlap(candidateItems)
|
|
335
|
+
if (collisionOk && kindOk(candidateItems)) { bestIdx = i; break outer }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return bestIdx
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Place a dragged item on the visual track its vertical travel points at,
|
|
343
|
+
* searching outward for one where it does not collide.
|
|
344
|
+
*
|
|
345
|
+
* Extracted verbatim from VisualTrackRow's drag handler so the canvas pointer
|
|
346
|
+
* machine (SP5 T5) lands items in exactly the same track the DOM rows would.
|
|
347
|
+
* The rules it encodes, none of them obvious from the outside:
|
|
348
|
+
*
|
|
349
|
+
* - Vertical travel is divided by `VISUAL_ROW_HEIGHT_PX` (24), not the rendered
|
|
350
|
+
* row height, so a drag reaches the neighbouring track before the cursor has
|
|
351
|
+
* fully left the current one. Downward travel LOWERS the track index, because
|
|
352
|
+
* tracks are stacked with the highest index on top.
|
|
353
|
+
* - "Collision" means overlapping an existing item by more than 30% of the
|
|
354
|
+
* dragged item's duration; brushing past a neighbour is allowed.
|
|
355
|
+
* - When the target track is occupied the search fans out — one above, one
|
|
356
|
+
* below, then two, and so on — and one step past the end of the array is a
|
|
357
|
+
* legal answer, which is how a drag creates a new top track. That search now
|
|
358
|
+
* lives in `resolveTargetTrackIdx` above, which this calls: the snap tier
|
|
359
|
+
* needs the same answer without the move.
|
|
360
|
+
* - Tracks left empty by the move are pruned, so dragging the last item off a
|
|
361
|
+
* track collapses it. The surviving TRACK OBJECTS are carried through the
|
|
362
|
+
* prune, so each keeps its own id and its own volume/muted/enabled. (This is
|
|
363
|
+
* the whole reason tracks are objects: with settings held in a parallel array
|
|
364
|
+
* indexed alongside `tracks`, a prune shifts every index above it and hands
|
|
365
|
+
* the wrong settings to the wrong track.)
|
|
366
|
+
* - A drag past the top of the stack mints a new track, with a fresh id from
|
|
367
|
+
* the same rule the normalizer uses and deduped against the ids already in
|
|
368
|
+
* play, so it can never collide with a surviving track.
|
|
369
|
+
*
|
|
370
|
+
* ── Ripple-insert (`makeSpace`, magnet/ripple mode ON) ────────────────────
|
|
371
|
+
* Everything above is the magnet-OFF path and is completely unchanged by
|
|
372
|
+
* `makeSpace` being available — with it omitted or false this function is
|
|
373
|
+
* byte-identical to before. When `makeSpace` is true (the pointer machine
|
|
374
|
+
* passes `ctx.rippleMode`), a collision at the pointed-at track
|
|
375
|
+
* (`resolveTargetTrackIdx`'s `targetIdx`)
|
|
376
|
+
* stops being disqualifying, CapCut-style: instead of fanning out to another
|
|
377
|
+
* track, the drop lands exactly where the drag points and PUSHES every item
|
|
378
|
+
* on that track whose `start` is at/after the dropped item's own `start` to
|
|
379
|
+
* the right by the dropped item's duration, making room for it in place. The
|
|
380
|
+
* search still runs — so the kind-lock and the "one past the end mints a track"
|
|
381
|
+
* rule are unchanged — but with `makeSpace` on it only ever fans out
|
|
382
|
+
* for a KIND mismatch, never for a collision, since collision no longer
|
|
383
|
+
* disqualifies a candidate. Dropping into a genuine gap (no collision at that
|
|
384
|
+
* same pointed-at track) is identical in both modes — there is nothing to push.
|
|
385
|
+
*/
|
|
386
|
+
export function moveItemAcrossTracks(args: CrossTrackMoveArgs): VisualTrack[] {
|
|
387
|
+
const { tracks, item, start, end, makeSpace = false } = args
|
|
388
|
+
const duration = end - start
|
|
389
|
+
const overlapMin = duration * 0.3
|
|
390
|
+
const bestIdx = resolveTargetTrackIdx(args)
|
|
391
|
+
|
|
392
|
+
function hasOverlap(items: VisualItem[]): boolean {
|
|
393
|
+
return items.some(other => {
|
|
394
|
+
if (other.id === item.id) return false
|
|
395
|
+
return Math.min(end, other.end) - Math.max(start, other.start) > overlapMin
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const removed = tracks.map(t => ({ ...t, items: t.items.filter(other => other.id !== item.id) }))
|
|
400
|
+
const movedItem = { ...item, start, end }
|
|
401
|
+
const newTrack = (): VisualTrack => ({
|
|
402
|
+
id: assignTrackId(removed.length, new Set(removed.map(t => t.id))),
|
|
403
|
+
items: [movedItem],
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
let placed: VisualTrack[]
|
|
407
|
+
if (bestIdx >= removed.length) {
|
|
408
|
+
placed = [...removed, newTrack()]
|
|
409
|
+
} else if (makeSpace && hasOverlap(removed[bestIdx].items)) {
|
|
410
|
+
// Ripple-insert: push every item on the target track that starts at or
|
|
411
|
+
// after the drop point to the right by the dragged item's own duration —
|
|
412
|
+
// `removed[bestIdx].items` already excludes the dragged item itself, so
|
|
413
|
+
// this can't shift the very item being placed — then land the dragged
|
|
414
|
+
// item at its dropped window.
|
|
415
|
+
placed = removed.map((t, i) => {
|
|
416
|
+
if (i !== bestIdx) return t
|
|
417
|
+
const shifted = t.items.map(other =>
|
|
418
|
+
other.start >= start ? { ...other, start: other.start + duration, end: other.end + duration } : other,
|
|
419
|
+
)
|
|
420
|
+
return { ...t, items: [...shifted, movedItem] }
|
|
421
|
+
})
|
|
422
|
+
} else {
|
|
423
|
+
placed = removed.map((t, i) => i === bestIdx ? { ...t, items: [...t.items, movedItem] } : t)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Re-group into the canonical video-block/overlay-block stack (see
|
|
427
|
+
// `normalizeTrackOrder`'s doc). This is what makes a freshly-minted video
|
|
428
|
+
// track — appended at the top by the mint above — land in the video block
|
|
429
|
+
// rather than staying stranded above the overlays, and makes every
|
|
430
|
+
// mid-drag transient frame already-canonical instead of relying on some
|
|
431
|
+
// later pass to re-normalize it.
|
|
432
|
+
return orderedTrackArray(placed.filter(t => t.items.length > 0))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Audio track update ───────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
/** Patch one audio track by id, leaving the rest of the project alone. Shared
|
|
438
|
+
* by the canvas pointer machine and Timeline.tsx so audio edits take the
|
|
439
|
+
* same shape wherever they're made. */
|
|
440
|
+
export function updateAudioTrack(project: Project, trackId: string, changes: Partial<AudioTrack>): Project {
|
|
441
|
+
return {
|
|
442
|
+
...project,
|
|
443
|
+
audio: {
|
|
444
|
+
...project.audio,
|
|
445
|
+
tracks: (project.audio?.tracks ?? []).map(t =>
|
|
446
|
+
t.id === trackId ? { ...t, ...changes } : t,
|
|
447
|
+
),
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Derived timing ───────────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
export interface DerivedTiming {
|
|
455
|
+
snapBoundaries: number[]
|
|
456
|
+
contentDuration: number
|
|
457
|
+
totalDuration: number
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Snap boundaries, content duration, and the zoom/scroll-padded total
|
|
461
|
+
* duration for a project's tracks + audio. Lifted from the render-time memo
|
|
462
|
+
* that used to live inline in Timeline so the canvas surface (T4) computes
|
|
463
|
+
* timing identically to the DOM surface. */
|
|
464
|
+
export function computeDerivedTiming(project: Project): DerivedTiming {
|
|
465
|
+
const allTracks = trackItems(project)
|
|
466
|
+
const audioTracks = project.audio?.tracks ?? []
|
|
467
|
+
const snapBoundaries = [...new Set([
|
|
468
|
+
...allTracks.flat().flatMap(c => [c.start, c.end]),
|
|
469
|
+
...audioTracks.flatMap(t => [t.start, t.end]),
|
|
470
|
+
])]
|
|
471
|
+
const contentDuration = Math.max(
|
|
472
|
+
allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
|
|
473
|
+
audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
|
|
474
|
+
)
|
|
475
|
+
// Add 20% padding beyond content so the rightmost item can always be
|
|
476
|
+
// dragged or resized further out. Minimum 5s headroom.
|
|
477
|
+
const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
|
|
478
|
+
return { snapBoundaries, contentDuration, totalDuration }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Auto-crossfade ───────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Auto-crossfade: when two audio tracks overlap, apply fade-out on the
|
|
485
|
+
* earlier track and fade-in on the later one, each equal to the overlap
|
|
486
|
+
* duration. Lifted out of Timeline's render-time effect — previously a
|
|
487
|
+
* hidden project mutation with no test coverage — so the canvas timeline
|
|
488
|
+
* (T4) can't silently drop the behavior.
|
|
489
|
+
*
|
|
490
|
+
* Returns `null` — the no-change signal — when no track's fade needs to
|
|
491
|
+
* change, so the caller's effect can skip calling `onProjectChange` and
|
|
492
|
+
* avoid re-triggering itself forever.
|
|
493
|
+
*/
|
|
494
|
+
export function computeAutoCrossfade(project: Project): Project | null {
|
|
495
|
+
const audioTracks = project.audio?.tracks ?? []
|
|
496
|
+
if (!audioTracks.length) return null
|
|
497
|
+
|
|
498
|
+
const sorted = [...audioTracks].sort((a, b) => a.start - b.start)
|
|
499
|
+
let changed = false
|
|
500
|
+
const updated = sorted.map(t => ({ ...t }))
|
|
501
|
+
|
|
502
|
+
// We only auto-set fades where overlap exists
|
|
503
|
+
for (let i = 0; i < updated.length - 1; i++) {
|
|
504
|
+
const a = updated[i]
|
|
505
|
+
const b = updated[i + 1]
|
|
506
|
+
if (a.end > b.start && !a.muted && !b.muted) {
|
|
507
|
+
// Overlap detected. Round ONCE and compare against the rounded value —
|
|
508
|
+
// comparing against the raw `overlap` made this non-idempotent whenever
|
|
509
|
+
// the overlap wasn't already a multiple of 0.1s (e.g. 0.37): the stored
|
|
510
|
+
// fade (0.4) would never equal the raw overlap (0.37), so `changed` was
|
|
511
|
+
// permanently true and this function kept reporting a change on a
|
|
512
|
+
// project it had already converged. Since Timeline.tsx's effect commits
|
|
513
|
+
// on every non-null result, that meant merely opening such a project
|
|
514
|
+
// wrote to disk and pushed a no-op undo entry, re-firing on every
|
|
515
|
+
// unrelated edit too.
|
|
516
|
+
const overlap = Math.min(a.end - b.start, a.end - a.start, b.end - b.start)
|
|
517
|
+
const rounded = Math.round(overlap * 10) / 10 // round to 0.1s
|
|
518
|
+
if ((a.fadeOut ?? 0) !== rounded) {
|
|
519
|
+
a.fadeOut = rounded
|
|
520
|
+
changed = true
|
|
521
|
+
}
|
|
522
|
+
if ((b.fadeIn ?? 0) !== rounded) {
|
|
523
|
+
b.fadeIn = rounded
|
|
524
|
+
changed = true
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!changed) return null
|
|
530
|
+
|
|
531
|
+
const trackMap = new Map(updated.map(t => [t.id, t]))
|
|
532
|
+
return {
|
|
533
|
+
...project,
|
|
534
|
+
audio: {
|
|
535
|
+
...project.audio,
|
|
536
|
+
tracks: audioTracks.map(t => trackMap.get(t.id) ?? t),
|
|
537
|
+
},
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ── Track shape (legacy VisualItem[][] ⟷ VisualTrack[]) ───────────────────
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Both-shapes tolerance for `project.tracks`.
|
|
545
|
+
*
|
|
546
|
+
* A project's `tracks` is on disk in one of two shapes. The legacy shape is a
|
|
547
|
+
* bare array of arrays — `[[item, item], [item]]` — with nowhere to hang a
|
|
548
|
+
* property that belongs to the TRACK rather than to a clip. The object shape
|
|
549
|
+
* (`VisualTrack[]`, see schema.ts) gives each track that place:
|
|
550
|
+
*
|
|
551
|
+
* [{ id: 'trk-0', items: [...] },
|
|
552
|
+
* { id: 'trk-1', items: [...], volume: 0.8, muted: false }]
|
|
553
|
+
*
|
|
554
|
+
* `volume`/`muted`/`enabled` are optional and ABSENT by default, so a
|
|
555
|
+
* normalized project behaves identically to the legacy one it came from. Track
|
|
556
|
+
* order is unchanged and still meaningful (`tracks[0]` is the primary footage
|
|
557
|
+
* track; higher indices render on top).
|
|
558
|
+
*
|
|
559
|
+
* The house rule is read-tolerant everywhere, write-normalized on open: readers
|
|
560
|
+
* call `trackItems()` and never care which shape is on disk; whoever opens the
|
|
561
|
+
* project calls `normalizeTracks()` and persists the result.
|
|
562
|
+
*
|
|
563
|
+
* Mirrored, semantics for semantics, by `lib/project_tracks.py` and
|
|
564
|
+
* `montaj_assets/render/project-tracks.js`. Change one, change all three — a
|
|
565
|
+
* legacy project normalized by any of them must produce the same ids and the
|
|
566
|
+
* same structure.
|
|
567
|
+
*/
|
|
568
|
+
|
|
569
|
+
/** Plain object (not an array, not null) — the shape a track object arrives as. */
|
|
570
|
+
function isTrackObject(v: unknown): v is Record<string, unknown> {
|
|
571
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** A usable track id: a non-empty string. */
|
|
575
|
+
function isTrackId(v: unknown): v is string {
|
|
576
|
+
return typeof v === 'string' && v.length > 0
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** True when `tracks` needs no work: every element is an object carrying a
|
|
580
|
+
* non-empty string `id` and an array `items`, and no two share an id. */
|
|
581
|
+
function isTrackObjectForm(tracks: unknown[]): boolean {
|
|
582
|
+
const seen = new Set<string>()
|
|
583
|
+
for (const track of tracks) {
|
|
584
|
+
if (!isTrackObject(track)) return false
|
|
585
|
+
if (!isTrackId(track.id)) return false
|
|
586
|
+
if (!Array.isArray(track.items)) return false
|
|
587
|
+
if (seen.has(track.id)) return false
|
|
588
|
+
seen.add(track.id)
|
|
589
|
+
}
|
|
590
|
+
return true
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Generate an id for the track at `index`, avoiding every id in `taken`. The
|
|
594
|
+
* rule: `trk-<index>`; if that is already taken, append an incrementing
|
|
595
|
+
* counter starting at 2 — `trk-<index>-2`, `trk-<index>-3`, … — until one is
|
|
596
|
+
* free. Deterministic and collision-free. `taken` is updated in place with the
|
|
597
|
+
* id handed out. */
|
|
598
|
+
function assignTrackId(index: number, taken: Set<string>): string {
|
|
599
|
+
let candidate = `trk-${index}`
|
|
600
|
+
let suffix = 2
|
|
601
|
+
while (taken.has(candidate)) {
|
|
602
|
+
candidate = `trk-${index}-${suffix}`
|
|
603
|
+
suffix += 1
|
|
604
|
+
}
|
|
605
|
+
taken.add(candidate)
|
|
606
|
+
return candidate
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* A fresh, collision-free track id for a track about to be appended at the
|
|
611
|
+
* end of `tracks` — the public door onto `assignTrackId` above, for callers
|
|
612
|
+
* outside this module that mint a whole new track (today: `placement.ts`'s
|
|
613
|
+
* "no free video track, make one" case). Delegates to the same private rule
|
|
614
|
+
* `normalizeTracks`/`moveItemAcrossTracks` use (`trk-<index>`, then
|
|
615
|
+
* `trk-<index>-2`, `-3`, … until free) rather than re-deriving it, so there
|
|
616
|
+
* remains exactly ONE implementation of the id convention.
|
|
617
|
+
*
|
|
618
|
+
* `index` is `tracks.length` — the new track's own position once appended —
|
|
619
|
+
* and `taken` is every id already in `tracks`, collected fresh on each call
|
|
620
|
+
* so a caller never has to keep its own id set in sync.
|
|
621
|
+
*/
|
|
622
|
+
export function nextVisualTrackId(tracks: readonly unknown[]): string {
|
|
623
|
+
const taken = new Set<string>()
|
|
624
|
+
for (const track of tracks) {
|
|
625
|
+
if (isTrackObject(track) && isTrackId(track.id)) taken.add(track.id)
|
|
626
|
+
}
|
|
627
|
+
return assignTrackId(tracks.length, taken)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Track group ordering ─────────────────────────────────────────────────
|
|
631
|
+
// Sam's decision: the timeline ALWAYS stacks (top→bottom) overlay tracks,
|
|
632
|
+
// then the caption band(s), then every VIDEO track as one contiguous block
|
|
633
|
+
// (base video at the bottom), then audio lanes. Video and overlay tracks
|
|
634
|
+
// stay SEPARATE — the kind-lock in `moveItemAcrossTracks` already blocks new
|
|
635
|
+
// cross-kind mixing, so this only has to group what's already there, never
|
|
636
|
+
// merge or split a track.
|
|
637
|
+
|
|
638
|
+
/** A track's coarse kind for STACKING purposes: 'video' if it holds at least
|
|
639
|
+
* one video item, 'overlay' otherwise (overlay/image items, or none at
|
|
640
|
+
* all). Distinct from `moveItemAcrossTracks`'s own item-level `coarse` —
|
|
641
|
+
* that one classifies a single dragged ITEM to gate a move; this one
|
|
642
|
+
* classifies a whole TRACK by its content, to group tracks for display. A
|
|
643
|
+
* track holding even one video item groups as video, mixed or not. */
|
|
644
|
+
function trackGroupKind(track: VisualTrack): 'video' | 'overlay' {
|
|
645
|
+
return track.items.some(item => item.type === 'video') ? 'video' : 'overlay'
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Stably partition `tracks` into the video group (in existing relative
|
|
650
|
+
* order) followed by the overlay group (in existing relative order).
|
|
651
|
+
* Returns the SAME array reference when the input is already in that order
|
|
652
|
+
* — the shared core both `normalizeTrackOrder` (below) and `normalizeTracks`
|
|
653
|
+
* call, so the identity contract lives in exactly one place.
|
|
654
|
+
*/
|
|
655
|
+
function orderedTrackArray(tracks: VisualTrack[]): VisualTrack[] {
|
|
656
|
+
const video: VisualTrack[] = []
|
|
657
|
+
const overlay: VisualTrack[] = []
|
|
658
|
+
for (const track of tracks) (trackGroupKind(track) === 'video' ? video : overlay).push(track)
|
|
659
|
+
const reordered = [...video, ...overlay]
|
|
660
|
+
return reordered.every((track, i) => track === tracks[i]) ? tracks : reordered
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Reorder `project.tracks` into the canonical video-block/overlay-block
|
|
665
|
+
* stack (see the section header above) — a STABLE partition, so it only
|
|
666
|
+
* moves the two groups past each other and never reorders within one. Base
|
|
667
|
+
* video (index 0 today) is always video-kind, so it always stays first.
|
|
668
|
+
*
|
|
669
|
+
* Only reorders `tracks` itself — never touches items, captions, or audio —
|
|
670
|
+
* and assumes `tracks` is already in `VisualTrack[]` object form; call
|
|
671
|
+
* `normalizeTracks` first for a project that might still be in the legacy
|
|
672
|
+
* array-of-arrays shape (its own hook below already applies this after).
|
|
673
|
+
*
|
|
674
|
+
* Identity-preserving: when `tracks` is ALREADY in canonical order, returns
|
|
675
|
+
* the exact SAME `project` object — no new array, no new project, not even
|
|
676
|
+
* a shallow copy. This is load-bearing, not a micro-optimisation: a
|
|
677
|
+
* normalizer that changes identity on already-canonical state defeats "same
|
|
678
|
+
* reference → no re-render, no new undo entry" for a project that hasn't
|
|
679
|
+
* actually changed, silently eating whatever gesture is mid-flight when it
|
|
680
|
+
* runs (see the `normalizeTrackOrder(canonical) === canonical` test).
|
|
681
|
+
*/
|
|
682
|
+
export function normalizeTrackOrder<P extends Project>(project: P): P {
|
|
683
|
+
const tracks = project.tracks
|
|
684
|
+
if (!Array.isArray(tracks) || tracks.length === 0) return project
|
|
685
|
+
const reordered = orderedTrackArray(tracks)
|
|
686
|
+
return reordered === tracks ? project : { ...project, tracks: reordered }
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Return `project` with `tracks` in object form AND in canonical stacking
|
|
691
|
+
* order (see `normalizeTrackOrder`). Accepts either shape.
|
|
692
|
+
*
|
|
693
|
+
* Pure — never mutates the input, at any depth. New track objects and new item
|
|
694
|
+
* ARRAYS are built; the item objects themselves are carried over by reference.
|
|
695
|
+
*
|
|
696
|
+
* Idempotent, and identity-preserving: when `tracks` is already in object form
|
|
697
|
+
* AND already in canonical order, the input object itself is returned, so
|
|
698
|
+
* `normalizeTracks(p) === p`. That identity is what the lazy on-open
|
|
699
|
+
* migration reads as "nothing to write" — a converged project must trigger
|
|
700
|
+
* no save. Same for a project with no `tracks`, a `tracks` of
|
|
701
|
+
* `null`/`undefined`, or a `tracks` that is not an array; validation is
|
|
702
|
+
* someone else's job and normalization never throws.
|
|
703
|
+
*
|
|
704
|
+
* Per element of `tracks`:
|
|
705
|
+
* - an array → `{ id: <generated>, items: <copy> }`
|
|
706
|
+
* - an object → preserved, with every other key (`volume`, `muted`,
|
|
707
|
+
* `enabled`, anything unknown) carried through untouched; `items` coerced
|
|
708
|
+
* to an array (missing / null / non-array → `[]`); `id` filled in when
|
|
709
|
+
* missing, not a string, empty, or a duplicate of an earlier track's id.
|
|
710
|
+
* - anything else (null, a string, a number) → `{ id: <generated>, items: [] }`
|
|
711
|
+
* Then the whole array is re-ordered per `normalizeTrackOrder` — so this is
|
|
712
|
+
* the ONE place that migrates a project to the current canonical shape,
|
|
713
|
+
* called on every open and after every track-affecting change (Sam's "always
|
|
714
|
+
* normalize" decision), rather than something callers opt into separately.
|
|
715
|
+
*
|
|
716
|
+
* The input side is deliberately structural and loose so this keeps compiling
|
|
717
|
+
* both while `Project.tracks` is `VisualItem[][]` and after it becomes
|
|
718
|
+
* `VisualTrack[]`.
|
|
719
|
+
*/
|
|
720
|
+
export function normalizeTracks<T extends { tracks?: unknown }>(project: T): T {
|
|
721
|
+
if (project === null || typeof project !== 'object') return project
|
|
722
|
+
const tracks: unknown = project.tracks
|
|
723
|
+
if (!Array.isArray(tracks)) return project
|
|
724
|
+
if (isTrackObjectForm(tracks)) {
|
|
725
|
+
const reordered = orderedTrackArray(tracks as VisualTrack[])
|
|
726
|
+
return reordered === tracks ? project : ({ ...project, tracks: reordered } as T)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Every explicit id in the project, collected up front so a generated
|
|
730
|
+
// `trk-<i>` can never land on an id a LATER track already claims.
|
|
731
|
+
const taken = new Set<string>()
|
|
732
|
+
for (const track of tracks) {
|
|
733
|
+
if (isTrackObject(track) && isTrackId(track.id)) taken.add(track.id)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const kept = new Set<string>() // ids handed out so far, so a duplicate loses to the first holder
|
|
737
|
+
const out = (tracks as unknown[]).map((track, index) => {
|
|
738
|
+
let normalized: Record<string, unknown>
|
|
739
|
+
if (Array.isArray(track)) {
|
|
740
|
+
normalized = { id: assignTrackId(index, taken), items: [...track] }
|
|
741
|
+
} else if (isTrackObject(track)) {
|
|
742
|
+
const items = Array.isArray(track.items) ? [...track.items] : []
|
|
743
|
+
const id = isTrackId(track.id) && !kept.has(track.id)
|
|
744
|
+
? track.id
|
|
745
|
+
: assignTrackId(index, taken)
|
|
746
|
+
normalized = { ...track, id, items }
|
|
747
|
+
} else {
|
|
748
|
+
normalized = { id: assignTrackId(index, taken), items: [] }
|
|
749
|
+
}
|
|
750
|
+
kept.add(normalized.id as string)
|
|
751
|
+
return normalized
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
// The spread widens T to `T & { tracks: … }`; the cast restores the caller's
|
|
755
|
+
// own project type, which is what it was apart from the tracks rebuild.
|
|
756
|
+
// A brand-new `tracks` array either way, so no identity to preserve here —
|
|
757
|
+
// just order it before handing it back.
|
|
758
|
+
return { ...project, tracks: orderedTrackArray(out as unknown as VisualTrack[]) } as T
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Just the items, in track order — for the many callers that only read.
|
|
763
|
+
* `[]` when the project has no tracks (or a `tracks` too malformed to
|
|
764
|
+
* normalize). The returned arrays are the normalized project's own item
|
|
765
|
+
* arrays; treat them as read-only.
|
|
766
|
+
*/
|
|
767
|
+
export function trackItems(project: { tracks?: unknown } | null | undefined): VisualItem[][] {
|
|
768
|
+
if (project === null || project === undefined || typeof project !== 'object') return []
|
|
769
|
+
const tracks: unknown = normalizeTracks(project).tracks
|
|
770
|
+
if (!Array.isArray(tracks)) return []
|
|
771
|
+
return (tracks as Array<{ items: VisualItem[] }>).map(t => t.items)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The items of ENABLED tracks only, in track order — for the surfaces that
|
|
776
|
+
* PRODUCE picture and sound.
|
|
777
|
+
*
|
|
778
|
+
* The counterpart to `trackItems`, and the split between them is the whole of
|
|
779
|
+
* the skip feature: editing surfaces (timeline, hit-testing, drag, trim, split,
|
|
780
|
+
* selection) call `trackItems` and keep seeing a skipped track, because you have
|
|
781
|
+
* to be able to see it and turn it back on. Playback and export call this one.
|
|
782
|
+
* Routing by which accessor a call site uses keeps the rule in one reviewable
|
|
783
|
+
* place instead of scattering `track.enabled === false` through a dozen files.
|
|
784
|
+
*
|
|
785
|
+
* `enabled` is absent by default, so `!== false` is the test: an untouched
|
|
786
|
+
* project has every track enabled.
|
|
787
|
+
*
|
|
788
|
+
* A skipped track's slot is EMPTIED, not removed — this maps, it does not
|
|
789
|
+
* filter. Every consumer of this array indexes it positionally (`[0]` is "the
|
|
790
|
+
* base footage track", `.slice(1)` is "the overlay tracks"); filtering would
|
|
791
|
+
* shift every later track's index down and silently reassign it to the wrong
|
|
792
|
+
* role. Emptying preserves position while still contributing nothing to
|
|
793
|
+
* playback or export.
|
|
794
|
+
*
|
|
795
|
+
* Items are passed through BY REFERENCE, never copied — the renderer's
|
|
796
|
+
* `resolveProjectPaths` mutates `item.src` in place through this view, so a
|
|
797
|
+
* defensive copy here would break path resolution with nothing failing.
|
|
798
|
+
*/
|
|
799
|
+
export function enabledTrackItems(project: { tracks?: unknown } | null | undefined): VisualItem[][] {
|
|
800
|
+
if (project === null || project === undefined || typeof project !== 'object') return []
|
|
801
|
+
const tracks: unknown = normalizeTracks(project).tracks
|
|
802
|
+
if (!Array.isArray(tracks)) return []
|
|
803
|
+
return (tracks as Array<{ items: VisualItem[]; enabled?: boolean }>)
|
|
804
|
+
.map(t => (t.enabled !== false ? t.items : []))
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* The ENABLED tracks themselves, in track order — the object-shape sibling of
|
|
809
|
+
* `enabledTrackItems`, for callers that need a track's own settings
|
|
810
|
+
* (`volume`, `muted`) alongside its items, not just the items. Both preview
|
|
811
|
+
* paths need it: they read their clips out of the first enabled track and then
|
|
812
|
+
* have to fold that track's audio settings into each one
|
|
813
|
+
* (`effectiveItemAudio`), which the flattened item-array view has nowhere to
|
|
814
|
+
* carry.
|
|
815
|
+
*
|
|
816
|
+
* Same treatment as `enabledTrackItems`: a skipped track's slot is emptied in
|
|
817
|
+
* place (`items: []`) rather than removed, so index i here is always index i
|
|
818
|
+
* there — the two accessors can never disagree about which tracks are "in",
|
|
819
|
+
* and neither shifts a later track into an earlier, positionally-meaningful
|
|
820
|
+
* slot.
|
|
821
|
+
*
|
|
822
|
+
* Tracks and items alike pass through BY REFERENCE; see `enabledTrackItems` on
|
|
823
|
+
* why copying items would break path resolution.
|
|
824
|
+
*
|
|
825
|
+
* Mirrors `enabledTracks` in `montaj_assets/render/project-tracks.js`.
|
|
826
|
+
*/
|
|
827
|
+
export function enabledTracks(project: { tracks?: unknown } | null | undefined): VisualTrack[] {
|
|
828
|
+
if (project === null || project === undefined || typeof project !== 'object') return []
|
|
829
|
+
const tracks: unknown = normalizeTracks(project).tracks
|
|
830
|
+
if (!Array.isArray(tracks)) return []
|
|
831
|
+
return (tracks as VisualTrack[]).map(t => (t.enabled !== false ? t : { ...t, items: [] }))
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ── Effective per-item audio (track × item fold) ────────────────────────
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Fold a track's volume/mute settings into one of its item's effective audio
|
|
838
|
+
* values. Volume **multiplies** — never replaces — so a clip an editor
|
|
839
|
+
* already turned down stays proportionally quieter under a track that's also
|
|
840
|
+
* pulled down; replacing would silently discard that per-clip work. Mute is
|
|
841
|
+
* **either/or** — either the track or the item being muted silences it.
|
|
842
|
+
*
|
|
843
|
+
* effectiveVolume = (track.volume ?? 1) * (item.volume ?? 1)
|
|
844
|
+
* effectiveMuted = track.muted === true || item.muted === true
|
|
845
|
+
*
|
|
846
|
+
* Pure, and tolerant of absent fields on either argument — `track`/`item` may
|
|
847
|
+
* each be `undefined`/`null`/`{}`. That is the common case: nothing writes
|
|
848
|
+
* `track.volume`/`track.muted` by default, so most calls see an absent track
|
|
849
|
+
* side and the result reduces to the item's own settings, unchanged.
|
|
850
|
+
*
|
|
851
|
+
* Mirrored by `effectiveItemAudio` in `montaj_assets/render/project-tracks.js`
|
|
852
|
+
* — the two must agree or preview and render will disagree on how loud a clip
|
|
853
|
+
* actually is.
|
|
854
|
+
*/
|
|
855
|
+
export function effectiveItemAudio(
|
|
856
|
+
track: Pick<VisualTrack, 'volume' | 'muted'> | null | undefined,
|
|
857
|
+
item: Pick<VisualItem, 'volume' | 'muted'> | null | undefined,
|
|
858
|
+
): { volume: number; muted: boolean } {
|
|
859
|
+
const volume = (track?.volume ?? 1) * (item?.volume ?? 1)
|
|
860
|
+
const muted = track?.muted === true || item?.muted === true
|
|
861
|
+
return { volume, muted }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* `project` with its `tracks` swapped for the bare items view — the adapter for
|
|
866
|
+
* the `@bycrux/timeline-core` boundary.
|
|
867
|
+
*
|
|
868
|
+
* That package is plain JS and its contract (`ResolverProject`,
|
|
869
|
+
* `DurationProject`) still reads `tracks` as an array of item arrays. It takes
|
|
870
|
+
* the WHOLE project, not just the tracks, so a caller can't route it through
|
|
871
|
+
* `trackItems()` the way every in-package reader does. Everything but `tracks`
|
|
872
|
+
* passes through by reference.
|
|
873
|
+
*/
|
|
874
|
+
export function withItemTracks<T extends { tracks?: unknown }>(
|
|
875
|
+
project: T,
|
|
876
|
+
): Omit<T, 'tracks'> & { tracks: VisualItem[][] } {
|
|
877
|
+
return { ...project, tracks: trackItems(project) }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* `withItemTracks`, but showing only the enabled tracks — the timeline-core
|
|
882
|
+
* adapter for playback and export.
|
|
883
|
+
*
|
|
884
|
+
* Both halves are required together. Filtering without the unwrap re-breaks
|
|
885
|
+
* `@bycrux/timeline-core` (it reads `tracks` as bare item arrays and throws
|
|
886
|
+
* `TypeError: object is not iterable` on the object shape); unwrapping without
|
|
887
|
+
* the filter silently ignores skip, so a skipped track keeps rendering. Use
|
|
888
|
+
* this at every project-level timeline-core call on a playback path.
|
|
889
|
+
*/
|
|
890
|
+
export function withEnabledItemTracks<T extends { tracks?: unknown }>(
|
|
891
|
+
project: T,
|
|
892
|
+
): Omit<T, 'tracks'> & { tracks: VisualItem[][] } {
|
|
893
|
+
return { ...project, tracks: enabledTrackItems(project) }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Rewrite every track's items, preserving each track's id and settings.
|
|
898
|
+
*
|
|
899
|
+
* The shape almost every edit takes: "map over the tracks, produce new items
|
|
900
|
+
* per track, keep everything else". Doing that by hand invites the bug this
|
|
901
|
+
* whole shape change exists to kill — rebuilding a track as a bare
|
|
902
|
+
* `{ id, items }` silently drops its `volume`/`muted`/`enabled`.
|
|
903
|
+
*
|
|
904
|
+
* Both-shapes tolerant on input (it normalizes first), always returns the
|
|
905
|
+
* object shape. Pure: new track objects, never a mutation of the input. `fn`
|
|
906
|
+
* receives the track's own item array — treat it as read-only — and the
|
|
907
|
+
* track's index, which is unchanged by this function (order is meaningful).
|
|
908
|
+
*
|
|
909
|
+
* Tracks left with no items are KEPT; pruning is `moveItemAcrossTracks`'
|
|
910
|
+
* and `deleteSelection`'s business and is spelled out explicitly there.
|
|
911
|
+
*/
|
|
912
|
+
export function mapTrackItems(
|
|
913
|
+
project: { tracks?: unknown },
|
|
914
|
+
fn: (items: VisualItem[], trackIndex: number) => VisualItem[],
|
|
915
|
+
): VisualTrack[] {
|
|
916
|
+
const tracks: unknown = normalizeTracks(project).tracks
|
|
917
|
+
if (!Array.isArray(tracks)) return []
|
|
918
|
+
return (tracks as VisualTrack[]).map((track, i) => ({ ...track, items: fn(track.items, i) }))
|
|
919
|
+
}
|