@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,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* captionActiveWord — derives the ONE caption word currently under the
|
|
3
|
+
* playhead, for the caption-styling panel's live font specimen (it shows the
|
|
4
|
+
* actual word from the user's video, rendered in the candidate typeface, so
|
|
5
|
+
* a font choice is judged against real content rather than a placeholder).
|
|
6
|
+
*
|
|
7
|
+
* DERIVED, NOT SELECTED — deliberately. The editor has no word-level
|
|
8
|
+
* selection: `selectedIds` (CaptionListPanel.tsx:76) is segment-level and
|
|
9
|
+
* shared across captions, clips, and audio. Inventing a word-selection model
|
|
10
|
+
* just for a font preview would mean touching that shared selection system
|
|
11
|
+
* for no benefit elsewhere. The playhead already tells us which word the
|
|
12
|
+
* user is looking at, so this module reads it from there instead.
|
|
13
|
+
*
|
|
14
|
+
* The active-segment test mirrors two existing call sites exactly rather than
|
|
15
|
+
* inventing a third notion of "active": the half-open `currentTime >= start
|
|
16
|
+
* && currentTime < end` predicate is `CaptionListPanel`'s row-highlight test
|
|
17
|
+
* and the caption render templates' `activeSegments` test. Segments can be
|
|
18
|
+
* simultaneously active across different lanes (`seg.lane`, see
|
|
19
|
+
* `captionLanes.ts`); when more than one is active this picks the operator's
|
|
20
|
+
* selection if it's among them, else the highest lane — the caption painted
|
|
21
|
+
* last and therefore on top — mirroring the target-segment pick in
|
|
22
|
+
* `preview/CaptionPreview.tsx`.
|
|
23
|
+
*/
|
|
24
|
+
import type { CaptionSegment, Captions } from '../schema'
|
|
25
|
+
import { laneOf } from './captionLanes'
|
|
26
|
+
|
|
27
|
+
export interface ActiveCaptionWord {
|
|
28
|
+
word: string
|
|
29
|
+
segmentId?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isSegmentActive = (seg: CaptionSegment, t: number): boolean => t >= seg.start && t < seg.end
|
|
33
|
+
|
|
34
|
+
/** Non-empty after trimming, else `undefined` — the shared guard behind the
|
|
35
|
+
* "never return an empty-string word" rule: a hand-authored segment can have
|
|
36
|
+
* blank `text` and no `words`, and that must read as "no word", not `''`. */
|
|
37
|
+
function nonEmpty(s: string | undefined): string | undefined {
|
|
38
|
+
const trimmed = s?.trim()
|
|
39
|
+
return trimmed ? s : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A segment's own first word, for when the playhead doesn't land inside any
|
|
43
|
+
* `words[]` span: the segment's first transcribed word if it has one,
|
|
44
|
+
* else the first whitespace-separated token of its `text` (segments authored
|
|
45
|
+
* without word-level timing, e.g. non-animated styles). */
|
|
46
|
+
function firstWordOfSegment(seg: CaptionSegment): string | undefined {
|
|
47
|
+
return nonEmpty(seg.words?.[0]?.word) ?? nonEmpty(seg.text?.trim().split(/\s+/)[0])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function activeCaptionWord(
|
|
51
|
+
captions: Captions | undefined,
|
|
52
|
+
currentTime: number,
|
|
53
|
+
selectedSegmentId?: string,
|
|
54
|
+
): ActiveCaptionWord | null {
|
|
55
|
+
const segments = captions?.segments ?? []
|
|
56
|
+
const activeSegs = segments.filter((seg) => isSegmentActive(seg, currentTime))
|
|
57
|
+
|
|
58
|
+
let target: CaptionSegment | undefined
|
|
59
|
+
if (activeSegs.length > 0) {
|
|
60
|
+
target =
|
|
61
|
+
(selectedSegmentId ? activeSegs.find((seg) => seg.id === selectedSegmentId) : undefined) ??
|
|
62
|
+
activeSegs.reduce((top, seg) => (laneOf(seg) > laneOf(top) ? seg : top))
|
|
63
|
+
} else if (selectedSegmentId) {
|
|
64
|
+
target = segments.find((seg) => seg.id === selectedSegmentId)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!target) return null
|
|
68
|
+
|
|
69
|
+
const spanningWord = target.words?.find((word) => currentTime >= word.start && currentTime < word.end)
|
|
70
|
+
const word = nonEmpty(spanningWord?.word) ?? firstWordOfSegment(target)
|
|
71
|
+
if (!word) return null
|
|
72
|
+
|
|
73
|
+
return { word, segmentId: target.id }
|
|
74
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { CaptionSegment, Captions } from '../schema'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Caption lanes — pure helpers over `CaptionSegment[]` / `Captions`. Every
|
|
5
|
+
* consumer that needs to know which lane a caption sits in, how lanes group
|
|
6
|
+
* up, or where a dragged caption should land reads it through this module, so
|
|
7
|
+
* lane semantics can't drift between the canvas painter, the caption
|
|
8
|
+
* sidebar, and the drag gesture that later phases build on top of this.
|
|
9
|
+
*
|
|
10
|
+
* See the `lane` field doc on `CaptionSegment` (schema.ts) for why caption
|
|
11
|
+
* lanes deliberately do NOT auto-increment the way `AudioTrack.lane` /
|
|
12
|
+
* `groupAudioLanes` do: absent means lane 0, full stop.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── Reading a segment's lane ────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A segment's lane, defaulted and defensively coerced. Absent, negative,
|
|
19
|
+
* non-integer, or non-finite (`NaN`/`Infinity`) stored values all collapse to
|
|
20
|
+
* a safe in-range integer rather than propagating into array indexing or
|
|
21
|
+
* comparisons downstream — a hand-edited project.json is the realistic source
|
|
22
|
+
* of a bad value here, not this codebase.
|
|
23
|
+
*/
|
|
24
|
+
export function laneOf(seg: CaptionSegment): number {
|
|
25
|
+
const raw = seg.lane
|
|
26
|
+
if (raw == null || !Number.isFinite(raw)) return 0
|
|
27
|
+
const n = Math.trunc(raw)
|
|
28
|
+
return n < 0 ? 0 : n
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Highest lane index present across `segments`, or 0 for an empty/lane-less
|
|
32
|
+
* track — so callers can size a `0..maxCaptionLane` structure without
|
|
33
|
+
* special-casing "no captions yet". */
|
|
34
|
+
export function maxCaptionLane(segments: CaptionSegment[]): number {
|
|
35
|
+
return segments.reduce((max, seg) => Math.max(max, laneOf(seg)), 0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Grouping ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface CaptionLaneGroup {
|
|
41
|
+
lane: number
|
|
42
|
+
segments: CaptionSegment[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Group `segments` into one entry per lane, `0..maxCaptionLane(segments)`,
|
|
47
|
+
* INCLUDING holes as empty groups (`segments: []`). A hole must be
|
|
48
|
+
* representable, not just skipped, because a later phase renders one row per
|
|
49
|
+
* lane and needs a visible, drop-targetable empty band mid-drag even where no
|
|
50
|
+
* caption currently lives.
|
|
51
|
+
*/
|
|
52
|
+
export function groupCaptionLanes(segments: CaptionSegment[]): CaptionLaneGroup[] {
|
|
53
|
+
const groups: CaptionLaneGroup[] = Array.from(
|
|
54
|
+
{ length: maxCaptionLane(segments) + 1 },
|
|
55
|
+
(_, lane) => ({ lane, segments: [] }),
|
|
56
|
+
)
|
|
57
|
+
for (const seg of segments) groups[laneOf(seg)].segments.push(seg)
|
|
58
|
+
return groups
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every segment whose (coerced) lane is `lane`. */
|
|
62
|
+
export function segmentsInLane(segments: CaptionSegment[], lane: number): CaptionSegment[] {
|
|
63
|
+
return segments.filter(seg => laneOf(seg) === lane)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Normalizing ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Collapse hole lanes and renumber dense from 0, preserving relative lane
|
|
70
|
+
* order (the segment(s) in the lowest occupied lane become lane 0, the next
|
|
71
|
+
* occupied lane becomes 1, and so on). Also rewrites any negative or
|
|
72
|
+
* non-integer stored `lane` to its canonical dense integer.
|
|
73
|
+
*
|
|
74
|
+
* Returns the SAME `Captions` reference when nothing changes — the contract
|
|
75
|
+
* every defensive normalizer in this codebase honours (see
|
|
76
|
+
* `backfillCaptionIds` in video/VideoEditor.tsx and `repairCaptionWords` in
|
|
77
|
+
* video/captionRepair.ts), so a caller that re-runs this after its own write
|
|
78
|
+
* converges to a true no-op instead of looping. Handles `captions` being
|
|
79
|
+
* `undefined` (passed straight back).
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeCaptionLanes(captions: Captions | undefined): Captions | undefined {
|
|
82
|
+
if (!captions) return captions
|
|
83
|
+
const { segments } = captions
|
|
84
|
+
if (!segments.length) return captions
|
|
85
|
+
|
|
86
|
+
// Distinct lanes actually in use (after defensive coercion), ascending —
|
|
87
|
+
// the lane at sorted position i renumbers to i, closing any holes. Lane 0,
|
|
88
|
+
// if present at all, is always the smallest and so always renumbers to 0.
|
|
89
|
+
const present = [...new Set(segments.map(laneOf))].sort((a, b) => a - b)
|
|
90
|
+
const remap = new Map(present.map((lane, i) => [lane, i]))
|
|
91
|
+
|
|
92
|
+
let changed = false
|
|
93
|
+
const newSegments = segments.map(seg => {
|
|
94
|
+
const canonical = remap.get(laneOf(seg))!
|
|
95
|
+
const current = seg.lane ?? 0
|
|
96
|
+
if (current === canonical) return seg
|
|
97
|
+
changed = true
|
|
98
|
+
return { ...seg, lane: canonical }
|
|
99
|
+
})
|
|
100
|
+
if (!changed) return captions
|
|
101
|
+
|
|
102
|
+
return { ...captions, segments: newSegments }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Drag support ─────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Does `[start, end)` avoid every OTHER segment already occupying `lane`?
|
|
109
|
+
* `id` names the segment being placed — if it happens to already sit in
|
|
110
|
+
* `lane`, it is excluded from the check, which is what makes it safe to call
|
|
111
|
+
* this on a segment's own prospective new position while dragging it.
|
|
112
|
+
* Touching edges (`end === seg.start` or `start === seg.end`) are not a
|
|
113
|
+
* collision.
|
|
114
|
+
*/
|
|
115
|
+
export function fitsInLane(
|
|
116
|
+
segments: CaptionSegment[],
|
|
117
|
+
id: string | undefined,
|
|
118
|
+
lane: number,
|
|
119
|
+
start: number,
|
|
120
|
+
end: number,
|
|
121
|
+
): boolean {
|
|
122
|
+
return segmentsInLane(segments, lane).every(other => {
|
|
123
|
+
if (id !== undefined && other.id === id) return true // the segment itself — not a collision
|
|
124
|
+
return end <= other.start || start >= other.end
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface CaptionLaneNeighbours {
|
|
129
|
+
prev: CaptionSegment | null
|
|
130
|
+
next: CaptionSegment | null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The nearest segments sharing `seg`'s lane on either side, by timeline
|
|
135
|
+
* position: `prev` is the closest segment ending at/before `seg.start`,
|
|
136
|
+
* `next` the closest starting at/after `seg.end`. `seg` itself is excluded
|
|
137
|
+
* (by id when it has one, otherwise by reference).
|
|
138
|
+
*/
|
|
139
|
+
export function sameLaneNeighbours(segments: CaptionSegment[], seg: CaptionSegment): CaptionLaneNeighbours {
|
|
140
|
+
const others = segmentsInLane(segments, laneOf(seg)).filter(other =>
|
|
141
|
+
seg.id !== undefined ? other.id !== seg.id : other !== seg,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
let prev: CaptionSegment | null = null
|
|
145
|
+
let next: CaptionSegment | null = null
|
|
146
|
+
for (const other of others) {
|
|
147
|
+
if (other.end <= seg.start && (!prev || other.end > prev.end)) prev = other
|
|
148
|
+
if (other.start >= seg.end && (!next || other.start < next.start)) next = other
|
|
149
|
+
}
|
|
150
|
+
return { prev, next }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ResolveDropLaneArgs {
|
|
154
|
+
/** Full segment list (including `seg` at its pre-drag position). */
|
|
155
|
+
segments: CaptionSegment[]
|
|
156
|
+
/** The segment being dragged. */
|
|
157
|
+
seg: CaptionSegment
|
|
158
|
+
/** The lane the drag started from. */
|
|
159
|
+
sourceLane: number
|
|
160
|
+
/** Lanes of vertical travel so far (see sign note below). */
|
|
161
|
+
laneDelta: number
|
|
162
|
+
/** The segment's prospective new [start, end) in the target lane. */
|
|
163
|
+
start: number
|
|
164
|
+
end: number
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The target lane for a vertical caption drag.
|
|
169
|
+
*
|
|
170
|
+
* Caption lanes ascend UPWARD on the timeline (higher lane number = higher on
|
|
171
|
+
* screen) — the OPPOSITE of audio lanes — so a downward drag (`laneDelta`
|
|
172
|
+
* increasing) must LOWER the lane number, hence the minus sign below. A later
|
|
173
|
+
* phase's drag gesture relies on this sign.
|
|
174
|
+
*
|
|
175
|
+
* Fans out from `wanted = clamp(sourceLane - laneDelta, 0, maxLane + 1)` in
|
|
176
|
+
* the order `wanted, wanted-1, wanted+1, wanted-2, …`, bounded to
|
|
177
|
+
* `[0, maxLane + 1]`, returning the first lane where `fitsInLane` holds. This
|
|
178
|
+
* mirrors the nearest-first fan-out `moveItemAcrossTracks` uses for visual
|
|
179
|
+
* tracks: land as close to the lane the pointer actually points at as
|
|
180
|
+
* possible, not just at the first open lane found in one direction.
|
|
181
|
+
*
|
|
182
|
+
* `maxLane + 1` is always in range and is empty of every segment (dragged one
|
|
183
|
+
* included — its OWN lane is at most `maxLane`, strictly less than
|
|
184
|
+
* `maxLane + 1`), so `fitsInLane` at that lane always holds. Because the
|
|
185
|
+
* fan-out's largest possible offset (`max(wanted, maxLane + 1 - wanted)`) never
|
|
186
|
+
* exceeds `maxLane + 1`, the search reaches that guaranteed-empty lane before
|
|
187
|
+
* running out of offsets to try — so this function always terminates with an
|
|
188
|
+
* answer, never falling through un-returned.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveDropLane({ segments, seg, sourceLane, laneDelta, start, end }: ResolveDropLaneArgs): number {
|
|
191
|
+
const upper = maxCaptionLane(segments) + 1
|
|
192
|
+
const wanted = Math.min(upper, Math.max(0, sourceLane - laneDelta))
|
|
193
|
+
|
|
194
|
+
for (let offset = 0; offset <= upper; offset++) {
|
|
195
|
+
const candidates = offset === 0 ? [wanted] : [wanted - offset, wanted + offset]
|
|
196
|
+
for (const candidate of candidates) {
|
|
197
|
+
if (candidate < 0 || candidate > upper) continue
|
|
198
|
+
if (fitsInLane(segments, seg.id, candidate, start, end)) return candidate
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return upper // unreachable — see doc comment above — kept only so the function is total
|
|
202
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* downstream save/notify).
|
|
12
12
|
*/
|
|
13
13
|
import type { Captions, CaptionSegment } from '../schema'
|
|
14
|
+
import { floorWordDurations } from './captionWordFloor'
|
|
14
15
|
|
|
15
16
|
// Collapses any run of whitespace to a single space, in addition to trim +
|
|
16
17
|
// case-fold, before the two sides are compared. `newWords` below is always
|
|
@@ -31,13 +32,16 @@ function repairSegment(seg: CaptionSegment): CaptionSegment {
|
|
|
31
32
|
const newWords = seg.text.split(/\s+/).filter(Boolean)
|
|
32
33
|
const segDur = seg.end - seg.start
|
|
33
34
|
const wordDur = segDur / (newWords.length || 1)
|
|
35
|
+
const spreadWords = newWords.map((w, i) => ({
|
|
36
|
+
word: w,
|
|
37
|
+
start: seg.start + i * wordDur,
|
|
38
|
+
end: seg.start + (i + 1) * wordDur,
|
|
39
|
+
}))
|
|
34
40
|
return {
|
|
35
41
|
...seg,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
end: seg.start + (i + 1) * wordDur,
|
|
40
|
-
})),
|
|
42
|
+
// Uniform spread has no minimum of its own — floor it so a short word
|
|
43
|
+
// never falls below a frame at any fps. See captionWordFloor.ts.
|
|
44
|
+
words: floorWordDurations(spreadWords, seg.end),
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-style default values for the caption text-styling fields — the ones
|
|
3
|
+
* each of the seven JSX caption templates applies via its own destructuring
|
|
4
|
+
* default (`fontWeight = 700`, etc.) whenever the corresponding `Captions`
|
|
5
|
+
* field is absent from the project.
|
|
6
|
+
*
|
|
7
|
+
* DELIBERATE DUPLICATION, the same shape as the `seg.lane ?? 0` duplication
|
|
8
|
+
* documented at the top of every template's `activeSegments` (see e.g.
|
|
9
|
+
* clean.jsx): the templates live in `montaj_assets/render/templates/
|
|
10
|
+
* captions/*.jsx`, compiled standalone for the browser/Puppeteer render
|
|
11
|
+
* path, and can import nothing but `montaj/render` — not this editor
|
|
12
|
+
* package, and not each other. The editor still needs these same numbers to
|
|
13
|
+
* show what an ABSENT field will actually render as — CaptionSpecimen's
|
|
14
|
+
* weight/family preview, and CaptionListPanel's steppers/case chips
|
|
15
|
+
* reporting the effective value instead of a blank one — and there is no
|
|
16
|
+
* module either side can import, so the values are copied here by hand.
|
|
17
|
+
*
|
|
18
|
+
* Whoever changes a template's own default MUST change the matching entry
|
|
19
|
+
* here too, in the same commit. Verified against the actual `function Xxx({
|
|
20
|
+
* ... })` destructuring defaults in:
|
|
21
|
+
* clean.jsx, karaoke.jsx, subtitle.jsx, pop.jsx, word-by-word.jsx,
|
|
22
|
+
* highlight-box.jsx, outline.jsx
|
|
23
|
+
* (all under montaj_assets/render/templates/captions/).
|
|
24
|
+
*/
|
|
25
|
+
import type { Captions } from '../schema'
|
|
26
|
+
|
|
27
|
+
type Style = Captions['style']
|
|
28
|
+
|
|
29
|
+
/** `Captions.fontWeight`'s default per style (CSS `font-weight`). */
|
|
30
|
+
export const CAPTION_STYLE_FONT_WEIGHT: Record<Style, number> = {
|
|
31
|
+
clean: 700,
|
|
32
|
+
karaoke: 700,
|
|
33
|
+
subtitle: 600,
|
|
34
|
+
pop: 800,
|
|
35
|
+
'word-by-word': 800,
|
|
36
|
+
'highlight-box': 900,
|
|
37
|
+
outline: 900,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `Captions.fontFamily`'s default per style. Six of the seven templates
|
|
41
|
+
* share the plain system-sans stack as their parameter default; only
|
|
42
|
+
* `clean` names a specific face (Figtree) as its designed look. */
|
|
43
|
+
export const CAPTION_STYLE_FONT_FAMILY: Record<Style, string> = {
|
|
44
|
+
clean: '"Figtree", system-ui, sans-serif',
|
|
45
|
+
karaoke: 'system-ui, -apple-system, sans-serif',
|
|
46
|
+
subtitle: 'system-ui, -apple-system, sans-serif',
|
|
47
|
+
pop: 'system-ui, -apple-system, sans-serif',
|
|
48
|
+
'word-by-word': 'system-ui, -apple-system, sans-serif',
|
|
49
|
+
'highlight-box': 'system-ui, -apple-system, sans-serif',
|
|
50
|
+
outline: 'system-ui, -apple-system, sans-serif',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `Captions.letterSpacing`'s default per style. A style absent from this
|
|
54
|
+
* table has no default at all — `karaoke`, `subtitle`, `highlight-box`, and
|
|
55
|
+
* `outline` all destructure `letterSpacing` with no `= ...`, so an unset
|
|
56
|
+
* field renders with the browser's normal spacing on those four. */
|
|
57
|
+
export const CAPTION_STYLE_LETTER_SPACING: Partial<Record<Style, string>> = {
|
|
58
|
+
clean: '0.01em',
|
|
59
|
+
pop: '-0.02em',
|
|
60
|
+
'word-by-word': '-0.02em',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `Captions.lineHeight`'s default per style.
|
|
64
|
+
*
|
|
65
|
+
* `highlight-box` and `outline` are a special case: their template has NO
|
|
66
|
+
* parameter-level default (a bare `lineHeight` destructure) — the value
|
|
67
|
+
* below only takes effect through an inline `lineHeight ?? 1.25` /
|
|
68
|
+
* `lineHeight ?? 1.15` at the WORDS-branch call site in `renderSegment`. A
|
|
69
|
+
* segment with no `words` array (the no-timestamps fallback branch) renders
|
|
70
|
+
* with no line-height at all, whether or not this field is set. `clean` and
|
|
71
|
+
* `subtitle` are plain parameter defaults and apply unconditionally.
|
|
72
|
+
* `karaoke`, `pop`, and `word-by-word` have no default in any branch. */
|
|
73
|
+
export const CAPTION_STYLE_LINE_HEIGHT: Partial<Record<Style, number>> = {
|
|
74
|
+
clean: 1.3,
|
|
75
|
+
subtitle: 1.4,
|
|
76
|
+
'highlight-box': 1.25,
|
|
77
|
+
outline: 1.15,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `Captions.textAlign`'s default per style — every template centers text by
|
|
81
|
+
* default, with no exceptions. */
|
|
82
|
+
export const CAPTION_STYLE_TEXT_ALIGN: Record<Style, string> = {
|
|
83
|
+
clean: 'center',
|
|
84
|
+
karaoke: 'center',
|
|
85
|
+
subtitle: 'center',
|
|
86
|
+
pop: 'center',
|
|
87
|
+
'word-by-word': 'center',
|
|
88
|
+
'highlight-box': 'center',
|
|
89
|
+
outline: 'center',
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `Captions.textTransform`'s default per style. Only `outline` has one — its
|
|
93
|
+
* all-caps stencil look — and, like its `lineHeight` default above, it only
|
|
94
|
+
* takes effect on the WORDS branch (`textTransform ?? 'uppercase'` at the
|
|
95
|
+
* `renderSegment` call site); the no-timestamps fallback branch renders the
|
|
96
|
+
* segment text as-is, never uppercased. Every other style has no default in
|
|
97
|
+
* any branch. */
|
|
98
|
+
export const CAPTION_STYLE_TEXT_TRANSFORM: Partial<Record<Style, string>> = {
|
|
99
|
+
outline: 'uppercase',
|
|
100
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* captionWordFloor — TS mirror of steps/lyrics/caption.py's
|
|
3
|
+
* `floor_word_durations`. Same waterfall, same constant (50ms — enough for
|
|
4
|
+
* >=1 frame at every fps montaj ships), same binary donation gate: a shared
|
|
5
|
+
* boundary between two words moves fully to `start_i + floor` or not at
|
|
6
|
+
* all, never partially, because the per-word caption templates select with
|
|
7
|
+
* `.find()` (first match wins) — a partial donation would leave a sliver of
|
|
8
|
+
* dead time where nothing matches, reproducing the invisibility bug the
|
|
9
|
+
* floor exists to fix. See caption.py's docstring for the full rationale;
|
|
10
|
+
* this file only documents the one deliberate divergence from it.
|
|
11
|
+
*
|
|
12
|
+
* WHY A MIRROR EXISTS AT ALL: caption.py is the single funnel for the SSE
|
|
13
|
+
* route and the CLI step, but two editor-side generators independently
|
|
14
|
+
* invent word timings with no minimum — `captionRepair.ts` and
|
|
15
|
+
* `makeCaptionEdit.ts`, both via a uniform `segDur / n` spread. This helper
|
|
16
|
+
* is applied to that spread's output, but on a uniform spread the donation
|
|
17
|
+
* gate is unsatisfiable by construction: it needs `2*dur - floor >= floor`,
|
|
18
|
+
* i.e. `dur >= floor`, which contradicts `dur < floor` — the precondition
|
|
19
|
+
* for a word being short enough to enter this function's donation logic at
|
|
20
|
+
* all. So the call is inert at both sites today; this file exists so a
|
|
21
|
+
* future non-uniform editor spread is covered without anyone having to
|
|
22
|
+
* remember it. (Hand-authored `PUT` captions stay uncovered by design —
|
|
23
|
+
* Montaj stores whatever it receives.)
|
|
24
|
+
*
|
|
25
|
+
* AND THAT INERTNESS IS DELIBERATE — do not "fix" it by widening the
|
|
26
|
+
* segment. The only way to give n words a floor inside a fixed segment is
|
|
27
|
+
* to make the segment longer (a uniform spread has no surplus to
|
|
28
|
+
* redistribute: `dur < floor` implies `segDur < n * floor`), and montaj
|
|
29
|
+
* follows CapCut here — editing a caption's text never moves its end.
|
|
30
|
+
* Duration is the user's to set; a caption holding more words than it has
|
|
31
|
+
* room for shows them fast, and the remedy is to lengthen or split the
|
|
32
|
+
* caption, both of which the timeline supports via caption trim handles.
|
|
33
|
+
* Auto-growing a caption because its text changed would surprise anyone
|
|
34
|
+
* coming from CapCut. See MASTER.md open thread 1.
|
|
35
|
+
*
|
|
36
|
+
* THE ONE DIVERGENCE: caption.py runs at the flatten, before any segment
|
|
37
|
+
* exists, so its transcript-final word has no `seg_end` to clamp against and
|
|
38
|
+
* is extended unconditionally into trailing silence. This mirror runs
|
|
39
|
+
* per-segment instead — both call sites regenerate `words` for ONE segment
|
|
40
|
+
* at a time from a freshly uniform-spread duration — so a segment end IS
|
|
41
|
+
* available here, and the segment-final word is extended only up to
|
|
42
|
+
* `segEnd`, never past it: overrunning the segment boundary would bleed
|
|
43
|
+
* this caption's last word into whatever comes next on the timeline.
|
|
44
|
+
*/
|
|
45
|
+
import type { Word } from '../schema'
|
|
46
|
+
|
|
47
|
+
export const MIN_WORD_DURATION_S = 0.05
|
|
48
|
+
|
|
49
|
+
export function floorWordDurations(words: Word[], segEnd: number, floorS = MIN_WORD_DURATION_S): Word[] {
|
|
50
|
+
if (words.length === 0) return words
|
|
51
|
+
|
|
52
|
+
const out = words.map((w) => ({ ...w }))
|
|
53
|
+
const n = out.length
|
|
54
|
+
for (let i = 0; i < n; i++) {
|
|
55
|
+
const dur = out[i].end - out[i].start
|
|
56
|
+
if (dur >= floorS) continue
|
|
57
|
+
|
|
58
|
+
if (i + 1 >= n) {
|
|
59
|
+
// Segment-final word: extend up to the floor, clamped at segEnd — the
|
|
60
|
+
// one divergence from caption.py, see file header.
|
|
61
|
+
out[i].end = Math.min(out[i].end + (floorS - dur), segEnd)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const gap = out[i + 1].start - out[i].end
|
|
66
|
+
if (gap > 0) {
|
|
67
|
+
// Non-contiguous: spend existing slack, never touch the successor.
|
|
68
|
+
out[i].end = Math.min(out[i].end + (floorS - dur), out[i + 1].start)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Contiguous: binary donation gate on the shared boundary.
|
|
73
|
+
const candidateBoundary = out[i].start + floorS
|
|
74
|
+
const successorNewDur = out[i + 1].end - candidateBoundary
|
|
75
|
+
if (successorNewDur >= floorS) {
|
|
76
|
+
out[i].end = candidateBoundary
|
|
77
|
+
out[i + 1].start = candidateBoundary
|
|
78
|
+
}
|
|
79
|
+
// else: donation refused. Word i is left completely unchanged.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return out
|
|
83
|
+
}
|