@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,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T2 — demux's pure parts.
|
|
3
|
+
*
|
|
4
|
+
* Everything here runs on synthetic sample tables: vitest/jsdom has no
|
|
5
|
+
* WebCodecs and no MP4 fixture, and the container-parsing half of `demux.ts`
|
|
6
|
+
* is mp4box's job, not ours. What *is* ours — and what SP1 proved is where
|
|
7
|
+
* the bugs live (`spikes/playback-engine/RESULTS.md` §7.1) — is the
|
|
8
|
+
* decode-order/presentation-order bookkeeping laid over those samples. Every
|
|
9
|
+
* table below therefore includes a B-frame pattern, because the all-intra
|
|
10
|
+
* case (what SP3's proxies actually are) cannot distinguish a correct
|
|
11
|
+
* implementation from the broken one: with cts order == dts order, sorting by
|
|
12
|
+
* presentation time and not sorting are the same answer. That is exactly how
|
|
13
|
+
* §7.1 survived to the measurement stage.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import type { MP4BoxSampleEntry, DataStream } from 'mp4box'
|
|
17
|
+
import {
|
|
18
|
+
buildPresIndex,
|
|
19
|
+
firstPresentationTsUs,
|
|
20
|
+
sampleAtOrBefore,
|
|
21
|
+
keyframeAtOrBefore,
|
|
22
|
+
audioSpecificConfigFor,
|
|
23
|
+
descriptionForEntries,
|
|
24
|
+
type SampleRef,
|
|
25
|
+
type SampleTable,
|
|
26
|
+
} from '../demux'
|
|
27
|
+
|
|
28
|
+
/** One synthetic sample. Times in µs; 1000µs "frames" keep the tables readable. */
|
|
29
|
+
function s(cts: number, dts: number, isKey = false): SampleRef {
|
|
30
|
+
return { tsUs: cts, dtsUs: dts, durUs: 1000, isKey, data: new Uint8Array(0) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function table(samples: SampleRef[]): SampleTable {
|
|
34
|
+
return { samples, presIndex: buildPresIndex(samples) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* All-intra, the shape SP3's AV1 proxies actually have: every sample a
|
|
39
|
+
* keyframe, cts == dts, no reordering.
|
|
40
|
+
*/
|
|
41
|
+
function allIntra(n = 8, originUs = 0): SampleRef[] {
|
|
42
|
+
return Array.from({ length: n }, (_, i) => s(originUs + i * 1000, originUs + i * 1000, true))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A classic reordered GOP: display order 0..7, decode order I0 P3 B1 B2 P6 B4
|
|
47
|
+
* B5 I7. Two sync samples — decode index 0 and decode index 7 — and, in the
|
|
48
|
+
* `closedGop` variant, a third at decode index 4 whose presentation time
|
|
49
|
+
* (6000) is LATER than frames that precede it in presentation order. That
|
|
50
|
+
* third keyframe is what separates a decode-order walk from a
|
|
51
|
+
* presentation-order one.
|
|
52
|
+
*/
|
|
53
|
+
function bFrameGop({ closedGop = false } = {}): SampleRef[] {
|
|
54
|
+
return [
|
|
55
|
+
s(0, 0, true), // 0: I0
|
|
56
|
+
s(3000, 1000), // 1: P3
|
|
57
|
+
s(1000, 2000), // 2: B1
|
|
58
|
+
s(2000, 3000), // 3: B2
|
|
59
|
+
s(6000, 4000, closedGop), // 4: P6 (or I6)
|
|
60
|
+
s(4000, 5000), // 5: B4
|
|
61
|
+
s(5000, 6000), // 6: B5
|
|
62
|
+
s(7000, 7000, true), // 7: I7
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('buildPresIndex', () => {
|
|
67
|
+
it('is the identity for an all-intra table (cts order == dts order)', () => {
|
|
68
|
+
expect(buildPresIndex(allIntra(8))).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('reorders a B-frame table into presentation order without touching the samples', () => {
|
|
72
|
+
const samples = bFrameGop()
|
|
73
|
+
const presIndex = buildPresIndex(samples)
|
|
74
|
+
// cts by decode index: [0, 3000, 1000, 2000, 6000, 4000, 5000, 7000]
|
|
75
|
+
expect(presIndex).toEqual([0, 2, 3, 1, 5, 6, 4, 7])
|
|
76
|
+
// The sample array itself must be untouched — decode order is the contract.
|
|
77
|
+
expect(samples.map((x) => x.dtsUs)).toEqual([0, 1000, 2000, 3000, 4000, 5000, 6000, 7000])
|
|
78
|
+
// And walking presIndex yields strictly ascending presentation times.
|
|
79
|
+
expect(presIndex.map((i) => samples[i].tsUs)).toEqual([0, 1000, 2000, 3000, 4000, 5000, 6000, 7000])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('is stable for duplicate presentation times (ties keep decode order)', () => {
|
|
83
|
+
const samples = [s(0, 0, true), s(1000, 1000), s(1000, 2000), s(1000, 3000), s(2000, 4000)]
|
|
84
|
+
expect(buildPresIndex(samples)).toEqual([0, 1, 2, 3, 4])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('handles an empty table', () => {
|
|
88
|
+
expect(buildPresIndex([])).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('firstPresentationTsUs', () => {
|
|
93
|
+
it('is samples[0] for an all-intra table starting at zero', () => {
|
|
94
|
+
const samples = allIntra(4)
|
|
95
|
+
expect(firstPresentationTsUs(samples, buildPresIndex(samples))).toBe(0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('tracks a non-zero media origin (timestamps need not start at 0)', () => {
|
|
99
|
+
const samples = allIntra(4, 1_500_000)
|
|
100
|
+
expect(firstPresentationTsUs(samples, buildPresIndex(samples))).toBe(1_500_000)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('is NOT samples[0] when decode order leads with a later presentation time', () => {
|
|
104
|
+
// Decode order starts on a frame whose cts is 2000; the true origin (0)
|
|
105
|
+
// sits at decode index 1. Reading samples[0].tsUs would be off by 2ms and
|
|
106
|
+
// every project↔media time conversion downstream would inherit it.
|
|
107
|
+
const samples = [s(2000, 0, true), s(0, 1000), s(1000, 2000)]
|
|
108
|
+
const presIndex = buildPresIndex(samples)
|
|
109
|
+
expect(samples[0].tsUs).toBe(2000)
|
|
110
|
+
expect(firstPresentationTsUs(samples, presIndex)).toBe(0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('is 0 for an empty table', () => {
|
|
114
|
+
expect(firstPresentationTsUs([], [])).toBe(0)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('sampleAtOrBefore', () => {
|
|
119
|
+
it('finds exact and in-between presentation times in an all-intra table', () => {
|
|
120
|
+
const t = table(allIntra(8))
|
|
121
|
+
expect(sampleAtOrBefore(t, 0)).toBe(0)
|
|
122
|
+
expect(sampleAtOrBefore(t, 3000)).toBe(3)
|
|
123
|
+
expect(sampleAtOrBefore(t, 3999)).toBe(3)
|
|
124
|
+
expect(sampleAtOrBefore(t, 7000)).toBe(7)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('clamps below the first frame and above the last', () => {
|
|
128
|
+
const t = table(allIntra(8, 1000))
|
|
129
|
+
expect(sampleAtOrBefore(t, -5000)).toBe(0)
|
|
130
|
+
expect(sampleAtOrBefore(t, 1e9)).toBe(7)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('returns a DECODE index, not a presentation rank (B-frame table)', () => {
|
|
134
|
+
const t = table(bFrameGop())
|
|
135
|
+
// t=2500 → latest cts <= 2500 is 2000, which is decode index 3 while
|
|
136
|
+
// being the 3rd frame in presentation order. Returning the rank (2) would
|
|
137
|
+
// slice the decode range at the wrong sample.
|
|
138
|
+
expect(sampleAtOrBefore(t, 2500)).toBe(3)
|
|
139
|
+
expect(t.samples[sampleAtOrBefore(t, 2500)].tsUs).toBe(2000)
|
|
140
|
+
// t=3000 lands on P3, which sits at decode index 1.
|
|
141
|
+
expect(sampleAtOrBefore(t, 3000)).toBe(1)
|
|
142
|
+
// t=6000 lands on P6 at decode index 4.
|
|
143
|
+
expect(sampleAtOrBefore(t, 6000)).toBe(4)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('returns 0 for an empty table', () => {
|
|
147
|
+
expect(sampleAtOrBefore({ samples: [], presIndex: [] }, 1000)).toBe(0)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('keyframeAtOrBefore', () => {
|
|
152
|
+
it('is the sample itself for an all-intra table (every frame is a sync sample)', () => {
|
|
153
|
+
const t = table(allIntra(8))
|
|
154
|
+
for (let i = 0; i < 8; i++) {
|
|
155
|
+
expect(keyframeAtOrBefore(t, i * 1000)).toBe(i)
|
|
156
|
+
expect(keyframeAtOrBefore(t, i * 1000)).toBe(sampleAtOrBefore(t, i * 1000))
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('walks back to the GOP head across a reordered run', () => {
|
|
161
|
+
const t = table(bFrameGop())
|
|
162
|
+
// Everything from decode index 1..6 belongs to the GOP opened at index 0.
|
|
163
|
+
expect(keyframeAtOrBefore(t, 2500)).toBe(0)
|
|
164
|
+
expect(keyframeAtOrBefore(t, 5000)).toBe(0)
|
|
165
|
+
// The last sample is itself a sync sample.
|
|
166
|
+
expect(keyframeAtOrBefore(t, 7000)).toBe(7)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('walks DECODE order, not presentation order — the SP1 §7.1 distinction', () => {
|
|
170
|
+
const t = table(bFrameGop({ closedGop: true }))
|
|
171
|
+
// Target t=5000 → B5 at decode index 6. Walking back through DECODE
|
|
172
|
+
// order hits the sync sample at decode index 4 first, so the decode run
|
|
173
|
+
// is [4..6] — three frames.
|
|
174
|
+
expect(sampleAtOrBefore(t, 5000)).toBe(6)
|
|
175
|
+
expect(keyframeAtOrBefore(t, 5000)).toBe(4)
|
|
176
|
+
// That keyframe's own presentation time (6000) is AFTER the target
|
|
177
|
+
// (5000). A walk that skipped samples with tsUs > tUs — i.e. one done in
|
|
178
|
+
// presentation order — would step straight over it and return 0,
|
|
179
|
+
// decoding seven frames instead of three. That is the class of mistake
|
|
180
|
+
// §7.1 documents, and it is undetectable on an all-intra source.
|
|
181
|
+
expect(t.samples[4].tsUs).toBeGreaterThan(5000)
|
|
182
|
+
expect(t.samples[4].isKey).toBe(true)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('falls back to decode index 0 when no sync sample precedes the target', () => {
|
|
186
|
+
// A table with no keyframe at all (a fragment starting mid-GOP).
|
|
187
|
+
const t = table([s(0, 0), s(1000, 1000), s(2000, 2000)])
|
|
188
|
+
expect(keyframeAtOrBefore(t, 2000)).toBe(0)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('returns 0 for an empty table', () => {
|
|
192
|
+
expect(keyframeAtOrBefore({ samples: [], presIndex: [] }, 1000)).toBe(0)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('audioSpecificConfigFor (the esds walk)', () => {
|
|
197
|
+
/** ES_Descriptor → DecoderConfigDescriptor (0x04) → DecoderSpecificInfo (0x05). */
|
|
198
|
+
function esdsEntry(descs: unknown): MP4BoxSampleEntry {
|
|
199
|
+
return { esds: { esd: descs } } as MP4BoxSampleEntry
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it('extracts the AudioSpecificConfig bytes from a well-formed tree', () => {
|
|
203
|
+
// 0x12 0x10 is the canonical AAC-LC 44.1kHz stereo AudioSpecificConfig.
|
|
204
|
+
const entry = esdsEntry({
|
|
205
|
+
tag: 0x03,
|
|
206
|
+
descs: [
|
|
207
|
+
{ tag: 0x06, descs: [] },
|
|
208
|
+
{ tag: 0x04, descs: [{ tag: 0x05, data: [0x12, 0x10] }] },
|
|
209
|
+
],
|
|
210
|
+
})
|
|
211
|
+
const out = audioSpecificConfigFor(entry)
|
|
212
|
+
expect(out).toBeInstanceOf(Uint8Array)
|
|
213
|
+
expect(Array.from(out!)).toEqual([0x12, 0x10])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('copies the bytes rather than aliasing mp4box’s array', () => {
|
|
217
|
+
const data = [0x12, 0x10]
|
|
218
|
+
const out = audioSpecificConfigFor(
|
|
219
|
+
esdsEntry({ tag: 0x03, descs: [{ tag: 0x04, descs: [{ tag: 0x05, data }] }] }),
|
|
220
|
+
)!
|
|
221
|
+
data[0] = 0xff
|
|
222
|
+
expect(out[0]).toBe(0x12)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('returns undefined when the entry has no esds at all', () => {
|
|
226
|
+
expect(audioSpecificConfigFor({})).toBeUndefined()
|
|
227
|
+
expect(audioSpecificConfigFor({ esds: {} })).toBeUndefined()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('returns undefined for a truncated tree (missing 0x04 or 0x05, or no data)', () => {
|
|
231
|
+
// Missing DecoderConfigDescriptor.
|
|
232
|
+
expect(audioSpecificConfigFor(esdsEntry({ tag: 0x03, descs: [{ tag: 0x06 }] }))).toBeUndefined()
|
|
233
|
+
// Present 0x04 but no DecoderSpecificInfo beneath it.
|
|
234
|
+
expect(
|
|
235
|
+
audioSpecificConfigFor(esdsEntry({ tag: 0x03, descs: [{ tag: 0x04, descs: [] }] })),
|
|
236
|
+
).toBeUndefined()
|
|
237
|
+
// Present 0x05 carrying no payload — the QuickTime-v1 AAC case that made
|
|
238
|
+
// the 4K original's audio unconfigurable in SP1 (§6/§7.1).
|
|
239
|
+
expect(
|
|
240
|
+
audioSpecificConfigFor(
|
|
241
|
+
esdsEntry({ tag: 0x03, descs: [{ tag: 0x04, descs: [{ tag: 0x05 }] }] }),
|
|
242
|
+
),
|
|
243
|
+
).toBeUndefined()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('does not search below the first level of each descriptor list', () => {
|
|
247
|
+
// 0x05 nested two levels down under an unrelated tag is not a
|
|
248
|
+
// DecoderSpecificInfo of this DecoderConfigDescriptor.
|
|
249
|
+
expect(
|
|
250
|
+
audioSpecificConfigFor(
|
|
251
|
+
esdsEntry({ tag: 0x03, descs: [{ tag: 0x0e, descs: [{ tag: 0x04, descs: [{ tag: 0x05, data: [1] }] }] }] }),
|
|
252
|
+
),
|
|
253
|
+
).toBeUndefined()
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('descriptionForEntries (the box dump)', () => {
|
|
258
|
+
/**
|
|
259
|
+
* A stand-in codec-config box that writes a real 8-byte ISOBMFF header
|
|
260
|
+
* (4-byte size + 4-byte fourcc) followed by its payload, using mp4box's own
|
|
261
|
+
* `DataStream` — so the header strip is asserted against the real writer.
|
|
262
|
+
*/
|
|
263
|
+
function box(fourcc: string, payload: number[]) {
|
|
264
|
+
const header = [
|
|
265
|
+
0,
|
|
266
|
+
0,
|
|
267
|
+
0,
|
|
268
|
+
8 + payload.length,
|
|
269
|
+
...Array.from(fourcc, (c) => c.charCodeAt(0)),
|
|
270
|
+
]
|
|
271
|
+
return {
|
|
272
|
+
write(stream: DataStream) {
|
|
273
|
+
stream.writeUint8Array(new Uint8Array([...header, ...payload]))
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
it('strips the 8-byte box header from av1C', () => {
|
|
279
|
+
const out = descriptionForEntries([{ av1C: box('av1C', [0x81, 0x05, 0x0c, 0x00]) }])
|
|
280
|
+
expect(Array.from(out!)).toEqual([0x81, 0x05, 0x0c, 0x00])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it.each(['avcC', 'hvcC', 'vpcC', 'av1C'] as const)('accepts %s', (fourcc) => {
|
|
284
|
+
const out = descriptionForEntries([{ [fourcc]: box(fourcc, [0xaa, 0xbb]) }])
|
|
285
|
+
expect(Array.from(out!)).toEqual([0xaa, 0xbb])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('falls through to the esds walk for an audio sample entry', () => {
|
|
289
|
+
const out = descriptionForEntries([
|
|
290
|
+
{ esds: { esd: { tag: 0x03, descs: [{ tag: 0x04, descs: [{ tag: 0x05, data: [0x12, 0x10] }] }] } } },
|
|
291
|
+
])
|
|
292
|
+
expect(Array.from(out!)).toEqual([0x12, 0x10])
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('skips entries carrying nothing usable and keeps looking', () => {
|
|
296
|
+
const out = descriptionForEntries([{}, { esds: {} }, { hvcC: box('hvcC', [0x07]) }])
|
|
297
|
+
expect(Array.from(out!)).toEqual([0x07])
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('returns undefined when no entry carries a description (a legitimate result)', () => {
|
|
301
|
+
// Opus in MP4 configures without one — this is not an error path.
|
|
302
|
+
expect(descriptionForEntries([])).toBeUndefined()
|
|
303
|
+
expect(descriptionForEntries([{}])).toBeUndefined()
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T1 — eligibility gate: pure project-shape check + the (mockable)
|
|
3
|
+
* WebCodecs capability probe. `proxySupport.test.ts` is the style precedent
|
|
4
|
+
* (independent module-level cache, reset per test via a `__setXForTests`
|
|
5
|
+
* hook). jsdom has no WebCodecs at all, so `VideoDecoder`/`AudioDecoder` are
|
|
6
|
+
* stubbed directly on `globalThis` per test, mirroring how
|
|
7
|
+
* `VideoEditor.test.tsx` stubs `AudioContext`/`ResizeObserver`.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
10
|
+
import {
|
|
11
|
+
checkProjectShapeEligibility,
|
|
12
|
+
engineCapabilitySupported,
|
|
13
|
+
evaluateEngineEligibility,
|
|
14
|
+
__setEngineCapabilityForTests,
|
|
15
|
+
} from '../eligibility'
|
|
16
|
+
import type { EditorProject as Project, VisualItem } from '../../schema'
|
|
17
|
+
|
|
18
|
+
function project(clips: VisualItem[]): Project {
|
|
19
|
+
return {
|
|
20
|
+
id: 'p1',
|
|
21
|
+
status: 'draft',
|
|
22
|
+
settings: { resolution: [1080, 1920] },
|
|
23
|
+
tracks: [{ id: 'trk-0', items: clips }],
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clip(overrides: Partial<VisualItem> = {}): VisualItem {
|
|
28
|
+
return { id: 'c1', type: 'video', start: 0, end: 5, proxySrc: '/a/orig_proxy_hable1.mp4', ...overrides }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stubVideoDecoder(supported: boolean) {
|
|
32
|
+
;(globalThis as unknown as { VideoDecoder: Pick<typeof VideoDecoder, 'isConfigSupported'> }).VideoDecoder = {
|
|
33
|
+
isConfigSupported: async () => ({ supported }),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stubAudioDecoder(supported: boolean) {
|
|
38
|
+
;(globalThis as unknown as { AudioDecoder: Pick<typeof AudioDecoder, 'isConfigSupported'> }).AudioDecoder = {
|
|
39
|
+
isConfigSupported: async () => ({ supported }),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
__setEngineCapabilityForTests(null)
|
|
45
|
+
delete (globalThis as unknown as { VideoDecoder?: unknown }).VideoDecoder
|
|
46
|
+
delete (globalThis as unknown as { AudioDecoder?: unknown }).AudioDecoder
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('checkProjectShapeEligibility', () => {
|
|
50
|
+
it('is eligible when every track-0 video item has proxySrc and no nobg_preview_src', () => {
|
|
51
|
+
expect(checkProjectShapeEligibility(project([clip(), clip({ id: 'c2' })]))).toEqual({ eligible: true })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('is vacuously eligible when track-0 has no video items (canvas-only project)', () => {
|
|
55
|
+
expect(checkProjectShapeEligibility(project([]))).toEqual({ eligible: true })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('ignores non-video track-0 items (background images) entirely', () => {
|
|
59
|
+
const bg: VisualItem = { id: 'img1', type: 'image', start: 0, end: 5 }
|
|
60
|
+
expect(checkProjectShapeEligibility(project([bg, clip()]))).toEqual({ eligible: true })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reports missing proxy', () => {
|
|
64
|
+
const result = checkProjectShapeEligibility(project([clip({ proxySrc: undefined })]))
|
|
65
|
+
expect(result.eligible).toBe(false)
|
|
66
|
+
expect(result.reason).toMatch(/proxySrc/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reports a nobg item even when it also has proxySrc', () => {
|
|
70
|
+
const result = checkProjectShapeEligibility(project([clip({ nobg_preview_src: '/a/nobg.webm' })]))
|
|
71
|
+
expect(result.eligible).toBe(false)
|
|
72
|
+
expect(result.reason).toMatch(/nobg_preview_src/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('stops at the first disqualifying clip (does not need to scan every item)', () => {
|
|
76
|
+
const result = checkProjectShapeEligibility(project([clip({ id: 'bad', proxySrc: undefined }), clip({ id: 'ok' })]))
|
|
77
|
+
expect(result.eligible).toBe(false)
|
|
78
|
+
expect(result.reason).toContain('bad')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('engineCapabilitySupported', () => {
|
|
83
|
+
it('is false when WebCodecs is absent (jsdom has no VideoDecoder/AudioDecoder)', async () => {
|
|
84
|
+
expect(await engineCapabilitySupported()).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('probes and caches VideoDecoder+AudioDecoder support (second call does not re-probe)', async () => {
|
|
88
|
+
let videoCalls = 0
|
|
89
|
+
;(globalThis as unknown as { VideoDecoder: Pick<typeof VideoDecoder, 'isConfigSupported'> }).VideoDecoder = {
|
|
90
|
+
isConfigSupported: async () => { videoCalls++; return { supported: true } },
|
|
91
|
+
}
|
|
92
|
+
stubAudioDecoder(true)
|
|
93
|
+
|
|
94
|
+
expect(await engineCapabilitySupported()).toBe(true)
|
|
95
|
+
expect(await engineCapabilitySupported()).toBe(true)
|
|
96
|
+
expect(videoCalls).toBe(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('is false when the video codec is unsupported even if audio is', async () => {
|
|
100
|
+
stubVideoDecoder(false)
|
|
101
|
+
stubAudioDecoder(true)
|
|
102
|
+
expect(await engineCapabilitySupported()).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('is false when the audio codec is unsupported even if video is', async () => {
|
|
106
|
+
stubVideoDecoder(true)
|
|
107
|
+
stubAudioDecoder(false)
|
|
108
|
+
expect(await engineCapabilitySupported()).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('never throws — a probe that rejects resolves to false', async () => {
|
|
112
|
+
;(globalThis as unknown as { VideoDecoder: Pick<typeof VideoDecoder, 'isConfigSupported'> }).VideoDecoder = {
|
|
113
|
+
isConfigSupported: async () => { throw new Error('boom') },
|
|
114
|
+
}
|
|
115
|
+
stubAudioDecoder(true)
|
|
116
|
+
await expect(engineCapabilitySupported()).resolves.toBe(false)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('evaluateEngineEligibility', () => {
|
|
121
|
+
it('all-good: shape passes and capability passes', async () => {
|
|
122
|
+
__setEngineCapabilityForTests(true)
|
|
123
|
+
expect(await evaluateEngineEligibility(project([clip()]))).toEqual({ eligible: true })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('unsupported codec: shape passes but the capability probe fails', async () => {
|
|
127
|
+
__setEngineCapabilityForTests(false)
|
|
128
|
+
const result = await evaluateEngineEligibility(project([clip()]))
|
|
129
|
+
expect(result.eligible).toBe(false)
|
|
130
|
+
expect(result.reason).toMatch(/WebCodecs/)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('missing proxy: shape fails before the capability probe is even consulted', async () => {
|
|
134
|
+
__setEngineCapabilityForTests(true)
|
|
135
|
+
const result = await evaluateEngineEligibility(project([clip({ proxySrc: undefined })]))
|
|
136
|
+
expect(result.eligible).toBe(false)
|
|
137
|
+
expect(result.reason).toMatch(/proxySrc/)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('nobg item: shape fails regardless of capability', async () => {
|
|
141
|
+
__setEngineCapabilityForTests(true)
|
|
142
|
+
const result = await evaluateEngineEligibility(project([clip({ nobg_preview_src: '/a/nobg.webm' })]))
|
|
143
|
+
expect(result.eligible).toBe(false)
|
|
144
|
+
expect(result.reason).toMatch(/nobg_preview_src/)
|
|
145
|
+
})
|
|
146
|
+
})
|