@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,107 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { activeCaptionWord } from '../captionActiveWord'
|
|
3
|
+
import type { CaptionSegment, Captions, Word } from '../../schema'
|
|
4
|
+
|
|
5
|
+
function w(word: string, start: number, end: number): Word {
|
|
6
|
+
return { word, start, end }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function seg(over: Partial<CaptionSegment> = {}): CaptionSegment {
|
|
10
|
+
return { text: 'x', start: 0, end: 1, ...over }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function captions(segments: CaptionSegment[]): Captions {
|
|
14
|
+
return { style: 'word-by-word', segments }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('activeCaptionWord', () => {
|
|
18
|
+
it('returns the word the playhead is inside', () => {
|
|
19
|
+
const track = captions([
|
|
20
|
+
seg({ id: 'a', start: 0, end: 2, text: 'hello world', words: [w('hello', 0, 1), w('world', 1, 2)] }),
|
|
21
|
+
])
|
|
22
|
+
expect(activeCaptionWord(track, 0.5)).toEqual({ word: 'hello', segmentId: 'a' })
|
|
23
|
+
expect(activeCaptionWord(track, 1.5)).toEqual({ word: 'world', segmentId: 'a' })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('falls back to the segment\'s first word when the playhead is in a gap between words', () => {
|
|
27
|
+
const track = captions([
|
|
28
|
+
seg({ id: 'a', start: 0, end: 2, text: 'hello world', words: [w('hello', 0, 0.4), w('world', 0.6, 1)] }),
|
|
29
|
+
])
|
|
30
|
+
// 0.5 is between the two words' spans (0.4-0.6 gap), but still inside the segment.
|
|
31
|
+
expect(activeCaptionWord(track, 0.5)).toEqual({ word: 'hello', segmentId: 'a' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('is inclusive at a word\'s start (half-open interval)', () => {
|
|
35
|
+
const track = captions([
|
|
36
|
+
seg({ id: 'a', start: 0, end: 2, text: 'hello world', words: [w('hello', 0, 1), w('world', 1, 2)] }),
|
|
37
|
+
])
|
|
38
|
+
expect(activeCaptionWord(track, 1)).toEqual({ word: 'world', segmentId: 'a' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('is exclusive at a word\'s end: falls to the next word if one starts there, else the gap fallback', () => {
|
|
42
|
+
const track = captions([
|
|
43
|
+
seg({ id: 'a', start: 0, end: 2, text: 'hello world', words: [w('hello', 0, 1), w('world', 1, 2)] }),
|
|
44
|
+
])
|
|
45
|
+
// t=1 lands on "hello".end / "world".start: "hello" is excluded, "world" matches.
|
|
46
|
+
expect(activeCaptionWord(track, 1)).toEqual({ word: 'world', segmentId: 'a' })
|
|
47
|
+
|
|
48
|
+
const trackWithGap = captions([
|
|
49
|
+
seg({ id: 'a', start: 0, end: 2, text: 'hello world', words: [w('hello', 0, 0.5), w('world', 0.6, 2)] }),
|
|
50
|
+
])
|
|
51
|
+
// t=0.5 lands on "hello".end but "world" doesn't start until 0.6: gap fallback.
|
|
52
|
+
expect(activeCaptionWord(trackWithGap, 0.5)).toEqual({ word: 'hello', segmentId: 'a' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('is exclusive at seg.end: the next segment is active there instead', () => {
|
|
56
|
+
const track = captions([
|
|
57
|
+
seg({ id: 'a', start: 0, end: 1, text: 'hello', words: [w('hello', 0, 1)] }),
|
|
58
|
+
seg({ id: 'b', start: 1, end: 2, text: 'world', words: [w('world', 1, 2)] }),
|
|
59
|
+
])
|
|
60
|
+
expect(activeCaptionWord(track, 1)).toEqual({ word: 'world', segmentId: 'b' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('uses the first whitespace-separated token of seg.text when there is no words array', () => {
|
|
64
|
+
const track = captions([seg({ id: 'a', start: 0, end: 1, text: 'hello world' })])
|
|
65
|
+
expect(activeCaptionWord(track, 0.5)).toEqual({ word: 'hello', segmentId: 'a' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('falls through without an empty word when text is empty/whitespace and there are no words', () => {
|
|
69
|
+
expect(activeCaptionWord(captions([seg({ id: 'a', start: 0, end: 1, text: '' })]), 0.5)).toBeNull()
|
|
70
|
+
expect(activeCaptionWord(captions([seg({ id: 'a', start: 0, end: 1, text: ' ' })]), 0.5)).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('falls back to the selected segment\'s first word when the playhead is between segments', () => {
|
|
74
|
+
const track = captions([
|
|
75
|
+
seg({ id: 'a', start: 0, end: 1, text: 'hello world' }),
|
|
76
|
+
seg({ id: 'b', start: 2, end: 3, text: 'goodbye moon' }),
|
|
77
|
+
])
|
|
78
|
+
expect(activeCaptionWord(track, 1.5, 'b')).toEqual({ word: 'goodbye', segmentId: 'b' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns null when the playhead is between segments with no selection', () => {
|
|
82
|
+
const track = captions([
|
|
83
|
+
seg({ id: 'a', start: 0, end: 1, text: 'hello world' }),
|
|
84
|
+
seg({ id: 'b', start: 2, end: 3, text: 'goodbye moon' }),
|
|
85
|
+
])
|
|
86
|
+
expect(activeCaptionWord(track, 1.5)).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns null for an empty caption track and for undefined captions, without throwing', () => {
|
|
90
|
+
expect(activeCaptionWord(captions([]), 0.5)).toBeNull()
|
|
91
|
+
expect(activeCaptionWord(undefined, 0.5)).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('prefers the highest lane when two segments are active at once, unless selection names the lower lane', () => {
|
|
95
|
+
const track = captions([
|
|
96
|
+
seg({ id: 'lo', start: 0, end: 2, text: 'bottom row', lane: 0 }),
|
|
97
|
+
seg({ id: 'hi', start: 0, end: 2, text: 'top row', lane: 1 }),
|
|
98
|
+
])
|
|
99
|
+
expect(activeCaptionWord(track, 1)).toEqual({ word: 'top', segmentId: 'hi' })
|
|
100
|
+
expect(activeCaptionWord(track, 1, 'lo')).toEqual({ word: 'bottom', segmentId: 'lo' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('splits on irregular internal whitespace when falling back to seg.text', () => {
|
|
104
|
+
const track = captions([seg({ id: 'a', start: 0, end: 1, text: ' hello world ' })])
|
|
105
|
+
expect(activeCaptionWord(track, 0.5)).toEqual({ word: 'hello', segmentId: 'a' })
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { CaptionSegment, Captions } from '../../schema'
|
|
3
|
+
import {
|
|
4
|
+
laneOf,
|
|
5
|
+
maxCaptionLane,
|
|
6
|
+
groupCaptionLanes,
|
|
7
|
+
normalizeCaptionLanes,
|
|
8
|
+
segmentsInLane,
|
|
9
|
+
fitsInLane,
|
|
10
|
+
sameLaneNeighbours,
|
|
11
|
+
resolveDropLane,
|
|
12
|
+
} from '../captionLanes'
|
|
13
|
+
|
|
14
|
+
function seg(over: Partial<CaptionSegment> = {}): CaptionSegment {
|
|
15
|
+
return { text: 'x', start: 0, end: 1, ...over }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function captions(segments: CaptionSegment[]): Captions {
|
|
19
|
+
return { style: 'subtitle', segments }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('laneOf', () => {
|
|
23
|
+
it('defaults an absent lane to 0', () => {
|
|
24
|
+
expect(laneOf(seg())).toBe(0)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('passes through a valid non-negative integer', () => {
|
|
28
|
+
expect(laneOf(seg({ lane: 3 }))).toBe(3)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('floors a negative lane at 0', () => {
|
|
32
|
+
expect(laneOf(seg({ lane: -5 }))).toBe(0)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('truncates a non-integer lane', () => {
|
|
36
|
+
expect(laneOf(seg({ lane: 2.9 }))).toBe(2)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('treats NaN/Infinity as absent', () => {
|
|
40
|
+
expect(laneOf(seg({ lane: NaN }))).toBe(0)
|
|
41
|
+
expect(laneOf(seg({ lane: Infinity }))).toBe(0)
|
|
42
|
+
expect(laneOf(seg({ lane: -Infinity }))).toBe(0)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('maxCaptionLane', () => {
|
|
47
|
+
it('is 0 for an empty list', () => {
|
|
48
|
+
expect(maxCaptionLane([])).toBe(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('is 0 for an all-lane-less (legacy) track', () => {
|
|
52
|
+
expect(maxCaptionLane([seg(), seg(), seg()])).toBe(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('is the highest coerced lane present', () => {
|
|
56
|
+
expect(maxCaptionLane([seg({ lane: 0 }), seg({ lane: 4 }), seg({ lane: 2 })])).toBe(4)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('groupCaptionLanes', () => {
|
|
61
|
+
it('puts a legacy all-lane-less track entirely in exactly one lane', () => {
|
|
62
|
+
const groups = groupCaptionLanes([seg({ text: 'a' }), seg({ text: 'b' }), seg({ text: 'c' })])
|
|
63
|
+
expect(groups).toHaveLength(1)
|
|
64
|
+
expect(groups[0].lane).toBe(0)
|
|
65
|
+
expect(groups[0].segments.map(s => s.text)).toEqual(['a', 'b', 'c'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('represents a hole between occupied lanes as an empty group', () => {
|
|
69
|
+
const groups = groupCaptionLanes([seg({ lane: 0, text: 'bottom' }), seg({ lane: 3, text: 'top' })])
|
|
70
|
+
expect(groups).toHaveLength(4)
|
|
71
|
+
expect(groups.map(g => g.lane)).toEqual([0, 1, 2, 3])
|
|
72
|
+
expect(groups[0].segments.map(s => s.text)).toEqual(['bottom'])
|
|
73
|
+
expect(groups[1].segments).toEqual([])
|
|
74
|
+
expect(groups[2].segments).toEqual([])
|
|
75
|
+
expect(groups[3].segments.map(s => s.text)).toEqual(['top'])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('segmentsInLane', () => {
|
|
80
|
+
it('returns only the segments coerced to the given lane', () => {
|
|
81
|
+
const a = seg({ lane: 0, text: 'a' })
|
|
82
|
+
const b = seg({ lane: -9, text: 'b' }) // coerces to 0
|
|
83
|
+
const c = seg({ lane: 1, text: 'c' })
|
|
84
|
+
expect(segmentsInLane([a, b, c], 0).map(s => s.text)).toEqual(['a', 'b'])
|
|
85
|
+
expect(segmentsInLane([a, b, c], 1).map(s => s.text)).toEqual(['c'])
|
|
86
|
+
expect(segmentsInLane([a, b, c], 2)).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('normalizeCaptionLanes', () => {
|
|
91
|
+
it('passes undefined captions through unchanged', () => {
|
|
92
|
+
expect(normalizeCaptionLanes(undefined)).toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns the SAME reference for empty captions', () => {
|
|
96
|
+
const c = captions([])
|
|
97
|
+
expect(normalizeCaptionLanes(c)).toBe(c)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('returns the SAME reference when lanes are already dense from 0', () => {
|
|
101
|
+
// Mix of absent (⇒ lane 0) and explicit values — both are already canonical.
|
|
102
|
+
const c = captions([seg({ text: 'a' }), seg({ lane: 1, text: 'b' }), seg({ lane: 2, text: 'c' })])
|
|
103
|
+
expect(normalizeCaptionLanes(c)).toBe(c)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('collapses a hole (0, 3) down to dense (0, 1)', () => {
|
|
107
|
+
const c = captions([seg({ lane: 0, text: 'bottom' }), seg({ lane: 3, text: 'top' })])
|
|
108
|
+
const out = normalizeCaptionLanes(c)!
|
|
109
|
+
expect(out).not.toBe(c)
|
|
110
|
+
expect(out.segments.find(s => s.text === 'bottom')!.lane ?? 0).toBe(0)
|
|
111
|
+
expect(out.segments.find(s => s.text === 'top')!.lane).toBe(1)
|
|
112
|
+
expect(maxCaptionLane(out.segments)).toBe(1)
|
|
113
|
+
// Fully dense now — a second pass changes nothing further.
|
|
114
|
+
expect(normalizeCaptionLanes(out)).toBe(out)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('normalizes sparse, negative, and non-integer lanes together to dense integers from 0', () => {
|
|
118
|
+
const bottom = seg({ text: 'absent' }) // absent ⇒ 0
|
|
119
|
+
const sparse = seg({ lane: 5, text: 'sparse' }) // sparse high lane
|
|
120
|
+
const negative = seg({ lane: -3, text: 'negative' }) // coerces to 0, alongside `bottom`
|
|
121
|
+
const fractional = seg({ lane: 2.7, text: 'fractional' }) // truncates to 2
|
|
122
|
+
|
|
123
|
+
const out = normalizeCaptionLanes(captions([bottom, sparse, negative, fractional]))!
|
|
124
|
+
const laneByText = Object.fromEntries(out.segments.map(s => [s.text, laneOf(s)]))
|
|
125
|
+
|
|
126
|
+
// Present coerced lanes were {0, 5, 0, 2} → three distinct lanes, renumbered
|
|
127
|
+
// in ascending order: 0→0, 2→1, 5→2.
|
|
128
|
+
expect(laneByText).toEqual({ absent: 0, negative: 0, fractional: 1, sparse: 2 })
|
|
129
|
+
expect(maxCaptionLane(out.segments)).toBe(2)
|
|
130
|
+
|
|
131
|
+
// Idempotent: normalizing the already-normalized result is a true no-op.
|
|
132
|
+
expect(normalizeCaptionLanes(out)).toBe(out)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('fitsInLane', () => {
|
|
137
|
+
const occupied = [seg({ id: 'a', lane: 0, start: 0, end: 5 }), seg({ id: 'b', lane: 0, start: 8, end: 10 })]
|
|
138
|
+
|
|
139
|
+
it('fits in a free gap', () => {
|
|
140
|
+
expect(fitsInLane(occupied, 'new', 0, 5, 8)).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('does not fit when it overlaps an existing segment', () => {
|
|
144
|
+
expect(fitsInLane(occupied, 'new', 0, 4, 6)).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('touching edges are not a collision', () => {
|
|
148
|
+
expect(fitsInLane(occupied, 'new', 0, 5, 8)).toBe(true) // butts both neighbors exactly
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('excludes the segment being placed from the collision check by id', () => {
|
|
152
|
+
// 'a' checked against its own current window — must not collide with itself.
|
|
153
|
+
expect(fitsInLane(occupied, 'a', 0, 0, 5)).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('an empty lane always fits', () => {
|
|
157
|
+
expect(fitsInLane(occupied, 'new', 7, 0, 100)).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('sameLaneNeighbours', () => {
|
|
162
|
+
it('finds the nearest prev/next in the same lane, ignoring other lanes', () => {
|
|
163
|
+
const target = seg({ id: 'mid', lane: 0, start: 10, end: 12 })
|
|
164
|
+
const segments = [
|
|
165
|
+
seg({ id: 'far-prev', lane: 0, start: 0, end: 2 }),
|
|
166
|
+
seg({ id: 'near-prev', lane: 0, start: 5, end: 8 }),
|
|
167
|
+
seg({ id: 'other-lane', lane: 1, start: 8, end: 9 }),
|
|
168
|
+
target,
|
|
169
|
+
seg({ id: 'near-next', lane: 0, start: 14, end: 16 }),
|
|
170
|
+
seg({ id: 'far-next', lane: 0, start: 20, end: 22 }),
|
|
171
|
+
]
|
|
172
|
+
const { prev, next } = sameLaneNeighbours(segments, target)
|
|
173
|
+
expect(prev?.id).toBe('near-prev')
|
|
174
|
+
expect(next?.id).toBe('near-next')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('returns nulls when alone in its lane', () => {
|
|
178
|
+
const target = seg({ id: 'solo', lane: 2, start: 0, end: 1 })
|
|
179
|
+
const { prev, next } = sameLaneNeighbours([target], target)
|
|
180
|
+
expect(prev).toBeNull()
|
|
181
|
+
expect(next).toBeNull()
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('resolveDropLane', () => {
|
|
186
|
+
it('lands directly on the wanted lane when it is free', () => {
|
|
187
|
+
const dragged = seg({ id: 'd', lane: 0, start: 0, end: 2 })
|
|
188
|
+
// A filler segment in lane 3 (elsewhere in time) just establishes maxLane
|
|
189
|
+
// so the clamp doesn't itself cap `wanted` below 2 in this fixture.
|
|
190
|
+
const filler = seg({ id: 'filler', lane: 3, start: 20, end: 22 })
|
|
191
|
+
const lane = resolveDropLane({
|
|
192
|
+
segments: [dragged, filler], seg: dragged, sourceLane: 0, laneDelta: -2, start: 0, end: 2,
|
|
193
|
+
})
|
|
194
|
+
expect(lane).toBe(2) // upward drag: sourceLane(0) - laneDelta(-2) = 2
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('a downward drag lowers the lane number (sign convention)', () => {
|
|
198
|
+
const seg1 = seg({ id: 'd', lane: 3, start: 0, end: 2 })
|
|
199
|
+
const lane = resolveDropLane({
|
|
200
|
+
segments: [seg1], seg: seg1, sourceLane: 3, laneDelta: 2, start: 0, end: 2,
|
|
201
|
+
})
|
|
202
|
+
expect(lane).toBe(1)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('floors the wanted lane at 0 — never goes negative', () => {
|
|
206
|
+
const seg1 = seg({ id: 'd', lane: 1, start: 0, end: 2 })
|
|
207
|
+
const lane = resolveDropLane({
|
|
208
|
+
segments: [seg1], seg: seg1, sourceLane: 1, laneDelta: 10, start: 0, end: 2,
|
|
209
|
+
})
|
|
210
|
+
expect(lane).toBe(0)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('fans out in order wanted, wanted-1, wanted+1: prefers wanted-1 when both are free', () => {
|
|
214
|
+
// wanted = 2 is occupied by a colliding segment; lanes 1 and 3 are both
|
|
215
|
+
// free at the drop window — order must pick 1 (wanted-1) over 3 (wanted+1).
|
|
216
|
+
const dragged = seg({ id: 'd', lane: 2, start: 0, end: 2 })
|
|
217
|
+
const blocker = seg({ id: 'blocker', lane: 2, start: 0, end: 2 })
|
|
218
|
+
const lane = resolveDropLane({
|
|
219
|
+
segments: [dragged, blocker], seg: dragged, sourceLane: 2, laneDelta: 0, start: 0, end: 2,
|
|
220
|
+
})
|
|
221
|
+
expect(lane).toBe(1)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('falls through to wanted+1 when wanted-1 is also occupied', () => {
|
|
225
|
+
const dragged = seg({ id: 'd', lane: 2, start: 0, end: 2 })
|
|
226
|
+
const blockAt2 = seg({ id: 'b2', lane: 2, start: 0, end: 2 })
|
|
227
|
+
const blockAt1 = seg({ id: 'b1', lane: 1, start: 0, end: 2 })
|
|
228
|
+
const lane = resolveDropLane({
|
|
229
|
+
segments: [dragged, blockAt2, blockAt1], seg: dragged, sourceLane: 2, laneDelta: 0, start: 0, end: 2,
|
|
230
|
+
})
|
|
231
|
+
expect(lane).toBe(3)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('mints the maxLane+1 lane when every existing lane collides', () => {
|
|
235
|
+
// Lanes 0, 1, 2 all carry a colliding segment at the drop window — the
|
|
236
|
+
// dragged segment (currently in an unrelated, non-colliding position)
|
|
237
|
+
// must land in the freshly minted lane 3.
|
|
238
|
+
const dragged = seg({ id: 'd', lane: 0, start: 50, end: 52 }) // elsewhere in time, doesn't block itself
|
|
239
|
+
const segments = [
|
|
240
|
+
dragged,
|
|
241
|
+
seg({ id: 'b0', lane: 0, start: 0, end: 2 }),
|
|
242
|
+
seg({ id: 'b1', lane: 1, start: 0, end: 2 }),
|
|
243
|
+
seg({ id: 'b2', lane: 2, start: 0, end: 2 }),
|
|
244
|
+
]
|
|
245
|
+
const lane = resolveDropLane({
|
|
246
|
+
segments, seg: dragged, sourceLane: 0, laneDelta: 0, start: 0, end: 2,
|
|
247
|
+
})
|
|
248
|
+
expect(lane).toBe(3) // maxLane(2) + 1
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('always terminates with an in-range lane even with many fully-occupied lanes', () => {
|
|
252
|
+
const dragged = seg({ id: 'd', lane: 0, start: 100, end: 102 })
|
|
253
|
+
const blockers = Array.from({ length: 30 }, (_, i) => seg({ id: `b${i}`, lane: i, start: 0, end: 2 }))
|
|
254
|
+
const segments = [dragged, ...blockers]
|
|
255
|
+
const lane = resolveDropLane({
|
|
256
|
+
segments, seg: dragged, sourceLane: 15, laneDelta: 0, start: 0, end: 2,
|
|
257
|
+
})
|
|
258
|
+
expect(lane).toBe(30) // maxLane(29) + 1, guaranteed empty
|
|
259
|
+
expect(Number.isInteger(lane)).toBe(true)
|
|
260
|
+
expect(lane).toBeGreaterThanOrEqual(0)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
@@ -18,9 +18,14 @@
|
|
|
18
18
|
// ResizeObserver calls back synchronously with a fixed size.
|
|
19
19
|
// - Range.prototype.getBoundingClientRect does not exist in jsdom at all
|
|
20
20
|
// (throws "is not a function"), and Element.prototype.getBoundingClientRect
|
|
21
|
-
// always returns zeros.
|
|
22
|
-
//
|
|
23
|
-
// that renders actual
|
|
21
|
+
// always returns zeros. measureCaptionContentRect (captionDragState.ts,
|
|
22
|
+
// called from CaptionPreview's layout effect) walks real text nodes via
|
|
23
|
+
// Range on every commit, so without a stub any test that renders actual
|
|
24
|
+
// caption content crashes.
|
|
25
|
+
//
|
|
26
|
+
// The fake template below emits no `data-caption-id`, deliberately: that also
|
|
27
|
+
// exercises measureCaptionContentRect's fallback to the whole root, which is
|
|
28
|
+
// what keeps a host-supplied or pre-lane template's selection box working.
|
|
24
29
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
25
30
|
import { render, waitFor, fireEvent } from '@testing-library/react'
|
|
26
31
|
import type { ReactElement } from 'react'
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { floorWordDurations, MIN_WORD_DURATION_S } from '../captionWordFloor'
|
|
3
|
+
import type { Word } from '../../schema'
|
|
4
|
+
|
|
5
|
+
// Mirrors tests/steps/test_caption.py's waterfall matrix for
|
|
6
|
+
// steps/lyrics/caption.py::floor_word_durations. The only behavioral
|
|
7
|
+
// difference under test is the segment-final word: here it's clamped at
|
|
8
|
+
// `segEnd` instead of extended unconditionally (this mirror runs
|
|
9
|
+
// per-segment, not at the transcript flatten — see captionWordFloor.ts).
|
|
10
|
+
|
|
11
|
+
function w(start: number, end: number, word = 'w'): Word {
|
|
12
|
+
return { word, start, end }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('floorWordDurations', () => {
|
|
16
|
+
it('is 50ms', () => {
|
|
17
|
+
expect(MIN_WORD_DURATION_S).toBe(0.05)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('is a no-op on an empty list', () => {
|
|
21
|
+
expect(floorWordDurations([], 1)).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('extends a single-word list (n==1) to the floor when segEnd allows it', () => {
|
|
25
|
+
// Pins the n==1 path: with no words after it, `i + 1 >= n` is already
|
|
26
|
+
// true at i=0, routing straight into the segment-final branch.
|
|
27
|
+
const words = [w(0, 0.02)]
|
|
28
|
+
const out = floorWordDurations(words, 1, 0.05)
|
|
29
|
+
expect(out[0].start).toBeCloseTo(0)
|
|
30
|
+
expect(out[0].end).toBeCloseTo(0.05)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('clamps a single-word list (n==1) at a tight segEnd', () => {
|
|
34
|
+
const words = [w(0, 0.02)]
|
|
35
|
+
const out = floorWordDurations(words, 0.03, 0.05)
|
|
36
|
+
expect(out[0].start).toBeCloseTo(0)
|
|
37
|
+
expect(out[0].end).toBeCloseTo(0.03)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('accepts a donation when the successor retains >= floor', () => {
|
|
41
|
+
// successor retains 0.400 - 0.050 = 0.350 >= floor
|
|
42
|
+
const words = [w(0, 0.02), w(0.02, 0.4)]
|
|
43
|
+
const out = floorWordDurations(words, 0.4, 0.05)
|
|
44
|
+
expect(out[0].start).toBeCloseTo(0)
|
|
45
|
+
expect(out[0].end).toBeCloseTo(0.05)
|
|
46
|
+
expect(out[1].start).toBeCloseTo(0.05)
|
|
47
|
+
expect(out[1].end).toBeCloseTo(0.4)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('accepts a donation when the successor retains EXACTLY the floor (inclusive boundary)', () => {
|
|
51
|
+
// Pins the >= in the donation gate: the spec is "retain >= floor_s", not
|
|
52
|
+
// "> floor_s" — no other case in this matrix distinguishes the two, so
|
|
53
|
+
// without this one a regression to `>` would leave the suite green.
|
|
54
|
+
// Exact, not float-luck: candidateBoundary = 0 + 0.05 = 0.05;
|
|
55
|
+
// successorNewDur = 0.10 - 0.05 = 0.05 === floor exactly at these
|
|
56
|
+
// values. Word 1's own original duration (0.08) is already >= floor, so
|
|
57
|
+
// it never enters the "short" branch itself, pre- or post-donation —
|
|
58
|
+
// this case is clean of the segment-final clamp too.
|
|
59
|
+
const words = [w(0, 0.02), w(0.02, 0.1)]
|
|
60
|
+
const out = floorWordDurations(words, 0.1, 0.05)
|
|
61
|
+
expect(out[0].start).toBeCloseTo(0)
|
|
62
|
+
expect(out[0].end).toBeCloseTo(0.05)
|
|
63
|
+
expect(out[1].start).toBeCloseTo(0.05)
|
|
64
|
+
expect(out[1].end).toBeCloseTo(0.1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('refuses a donation when the successor would drop below floor, leaving word i unchanged', () => {
|
|
68
|
+
// successor would retain only 0.060 - 0.050 = 0.010 < floor: refused.
|
|
69
|
+
// Word 1 here is the segment-final word (clamped at segEnd=0.06, so no
|
|
70
|
+
// room to extend) — it stays exactly as-is too, unlike the Python
|
|
71
|
+
// mirror's unconditional-extension case tested separately below.
|
|
72
|
+
const words = [w(0, 0.02), w(0.02, 0.06)]
|
|
73
|
+
const out = floorWordDurations(words, 0.06, 0.05)
|
|
74
|
+
expect(out[0]).toEqual(w(0, 0.02))
|
|
75
|
+
expect(out[1]).toEqual(w(0.02, 0.06))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does not touch a final word at exactly the floor duration, even with room past segEnd to spare', () => {
|
|
79
|
+
// Pins the TRIGGER condition, not just the segEnd clamp: with segEnd far
|
|
80
|
+
// past the word's current end there is plenty of room to extend, yet a
|
|
81
|
+
// word already AT the floor never qualifies as short in the first
|
|
82
|
+
// place (the `dur >= floorS` skip fires before any extension logic).
|
|
83
|
+
const words = [w(0, 0.5), w(0.5, 0.55)] // duration exactly 0.05 == floor
|
|
84
|
+
const out = floorWordDurations(words, 10, 0.05)
|
|
85
|
+
expect(out).toEqual(words)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not touch a final word comfortably above the floor', () => {
|
|
89
|
+
const words = [w(0, 0.5), w(0.5, 0.62)] // duration 0.12
|
|
90
|
+
const out = floorWordDurations(words, 10, 0.05)
|
|
91
|
+
expect(out).toEqual(words)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('leaves an entirely starved run unchanged when every donation is refused and the last word is not short', () => {
|
|
95
|
+
const words = [w(0, 0.02), w(0.02, 0.04), w(0.04, 0.06), w(0.06, 0.11)]
|
|
96
|
+
const out = floorWordDurations(words, 0.11, 0.05)
|
|
97
|
+
expect(out).toEqual(words)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('rescues only the starved run\'s last boundary when it is followed by a long word (per-boundary, not per-run)', () => {
|
|
101
|
+
const words = [w(0, 0.02), w(0.02, 0.04), w(0.04, 0.06), w(0.06, 0.5)]
|
|
102
|
+
const out = floorWordDurations(words, 0.5, 0.05)
|
|
103
|
+
expect(out[0]).toEqual(w(0, 0.02))
|
|
104
|
+
expect(out[1]).toEqual(w(0.02, 0.04))
|
|
105
|
+
expect(out[2].start).toBeCloseTo(0.04)
|
|
106
|
+
expect(out[2].end).toBeCloseTo(0.09)
|
|
107
|
+
expect(out[3].start).toBeCloseTo(0.09)
|
|
108
|
+
expect(out[3].end).toBeCloseTo(0.5)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('extends a non-contiguous word into an existing gap without touching the successor start', () => {
|
|
112
|
+
const words = [w(0, 0.02), w(0.1, 0.2)]
|
|
113
|
+
const out = floorWordDurations(words, 0.2, 0.05)
|
|
114
|
+
expect(out[0].start).toBeCloseTo(0)
|
|
115
|
+
expect(out[0].end).toBeCloseTo(0.05)
|
|
116
|
+
expect(out[1]).toEqual(w(0.1, 0.2))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('caps the non-contiguous extension at the gap size, not the full floor deficit', () => {
|
|
120
|
+
const words = [w(0, 0.02), w(0.03, 0.2)]
|
|
121
|
+
const out = floorWordDurations(words, 0.2, 0.05)
|
|
122
|
+
expect(out[0].start).toBeCloseTo(0)
|
|
123
|
+
expect(out[0].end).toBeCloseTo(0.03)
|
|
124
|
+
expect(out[1]).toEqual(w(0.03, 0.2))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('leaves already-long words untouched (identical values out)', () => {
|
|
128
|
+
const words = [w(0, 0.5), w(0.5, 1.0), w(1.0, 1.8)]
|
|
129
|
+
const out = floorWordDurations(words, 1.8, 0.05)
|
|
130
|
+
expect(out).toEqual(words)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('never creates overlap', () => {
|
|
134
|
+
const matrices: Word[][] = [
|
|
135
|
+
[w(0, 0.02), w(0.02, 0.4)],
|
|
136
|
+
[w(0, 0.02), w(0.02, 0.06)],
|
|
137
|
+
[w(0, 0.02), w(0.02, 0.04), w(0.04, 0.06), w(0.06, 0.11)],
|
|
138
|
+
[w(0, 0.02), w(0.02, 0.04), w(0.04, 0.06), w(0.06, 0.5)],
|
|
139
|
+
[w(0, 0.02), w(0.1, 0.2)],
|
|
140
|
+
[w(0, 0.02), w(0.03, 0.2)],
|
|
141
|
+
]
|
|
142
|
+
for (const words of matrices) {
|
|
143
|
+
const segEnd = words[words.length - 1].end
|
|
144
|
+
const out = floorWordDurations(words, segEnd, 0.05)
|
|
145
|
+
for (let i = 0; i < out.length - 1; i++) {
|
|
146
|
+
expect(out[i].end).toBeLessThanOrEqual(out[i + 1].start + 1e-9)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('never introduces a gap where words were previously flush', () => {
|
|
152
|
+
// word-by-word.jsx falls back to rendering the segment's LAST word when
|
|
153
|
+
// no word matches the current time — a gap introduced mid-segment would
|
|
154
|
+
// make the caption visibly jump to its final word and back. The
|
|
155
|
+
// waterfall must never do this: it only moves a shared boundary forward
|
|
156
|
+
// together, and the non-contiguous branch only ever shrinks a gap.
|
|
157
|
+
const matrices: Word[][] = [
|
|
158
|
+
[w(0, 0.02), w(0.02, 0.4)],
|
|
159
|
+
[w(0, 0.02), w(0.02, 0.06)],
|
|
160
|
+
[w(0, 0.02), w(0.02, 0.04), w(0.04, 0.06), w(0.06, 0.11)],
|
|
161
|
+
[w(0, 0.02), w(0.02, 0.04), w(0.04, 0.06), w(0.06, 0.5)],
|
|
162
|
+
]
|
|
163
|
+
for (const words of matrices) {
|
|
164
|
+
const segEnd = words[words.length - 1].end
|
|
165
|
+
const flushBefore = words.slice(0, -1).map((word, i) => word.end === words[i + 1].start)
|
|
166
|
+
const out = floorWordDurations(words, segEnd, 0.05)
|
|
167
|
+
flushBefore.forEach((wasFlush, i) => {
|
|
168
|
+
if (wasFlush) expect(out[i].end).toBe(out[i + 1].start)
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not mutate the input', () => {
|
|
174
|
+
const words = [w(0, 0.02), w(0.02, 0.4)]
|
|
175
|
+
const snapshot = words.map((word) => ({ ...word }))
|
|
176
|
+
floorWordDurations(words, 0.4, 0.05)
|
|
177
|
+
expect(words).toEqual(snapshot)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('segment-final clamp (divergence from the Python mirror)', () => {
|
|
181
|
+
it('extends a short segment-final word only up to segEnd, never past it', () => {
|
|
182
|
+
const words = [w(0, 0.4), w(0.4, 0.42)]
|
|
183
|
+
const out = floorWordDurations(words, 0.42, 0.05)
|
|
184
|
+
expect(out[0]).toEqual(w(0, 0.4))
|
|
185
|
+
// Deficit would be 0.03 (→ 0.45) but segEnd caps it at 0.42.
|
|
186
|
+
expect(out[1].start).toBeCloseTo(0.4)
|
|
187
|
+
expect(out[1].end).toBeCloseTo(0.42)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('extends a short segment-final word up to the floor when segEnd leaves enough room', () => {
|
|
191
|
+
const words = [w(0, 0.4), w(0.4, 0.42)]
|
|
192
|
+
const out = floorWordDurations(words, 0.46, 0.05)
|
|
193
|
+
expect(out[1].start).toBeCloseTo(0.4)
|
|
194
|
+
expect(out[1].end).toBeCloseTo(0.45)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('uniform spread (the actual editor-side call site)', () => {
|
|
199
|
+
it('is a no-op on a uniform segDur / n spread: the donation gate can never fire', () => {
|
|
200
|
+
// 4 words uniformly spread across 0.12s → 0.03s each, all sub-floor.
|
|
201
|
+
// For a contiguous run of equal-duration words, successorNewDur works
|
|
202
|
+
// out to 2*dur - floor, so the gate (successorNewDur >= floor)
|
|
203
|
+
// reduces to dur >= floor -- which contradicts dur < floor, the
|
|
204
|
+
// precondition for a word entering the donation branch at all. So no
|
|
205
|
+
// boundary in a uniform spread is ever eligible: this is not "starves
|
|
206
|
+
// gracefully", it is a hard no-op. (The segment-final word is a
|
|
207
|
+
// separate no-op too: a uniform spread's last word already ends
|
|
208
|
+
// exactly at segEnd, so the clamp there is a no-op by construction.)
|
|
209
|
+
// This pins that the editor-side call sites (captionRepair.ts,
|
|
210
|
+
// makeCaptionEdit.ts) are currently inert, not that they work. That is
|
|
211
|
+
// the intended behaviour, not a latent bug: a uniform editor spread is
|
|
212
|
+
// deliberately left unfloored to match CapCut (edited caption text
|
|
213
|
+
// never changes the caption's duration; lengthen or split it instead).
|
|
214
|
+
// Don't "fix" this test by making it non-vacuous in the direction of
|
|
215
|
+
// asserting the floor should fire here — that would be reintroducing
|
|
216
|
+
// the behaviour this test exists to keep out.
|
|
217
|
+
const n = 4
|
|
218
|
+
const segStart = 0
|
|
219
|
+
const segEnd = 0.12
|
|
220
|
+
const wordDur = (segEnd - segStart) / n
|
|
221
|
+
const spread = Array.from({ length: n }, (_, i) =>
|
|
222
|
+
w(segStart + i * wordDur, segStart + (i + 1) * wordDur, `w${i}`),
|
|
223
|
+
)
|
|
224
|
+
const out = floorWordDurations(spread, segEnd, 0.05)
|
|
225
|
+
expect(out).toEqual(spread)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
})
|