@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,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T1 — the clock → painter bridge subtracts the shared AudioContext's
|
|
3
|
+
* `outputLatency + baseLatency` on every emit, so the painted playhead
|
|
4
|
+
* matches what is actually AUDIBLE (not what the frames-consumed clock
|
|
5
|
+
* has counted). Both the engine bridge (`useEnginePlayback.emitTime`)
|
|
6
|
+
* and the `<video>` fallback bridge (`useVideoPlayback`'s playback-advance
|
|
7
|
+
* callsites) go through this compensation.
|
|
8
|
+
*
|
|
9
|
+
* The fake ctx (`stubCtx`) is written straight into `window.__montajSharedCtx`
|
|
10
|
+
* because `getSharedAudioContext()` only rebuilds when the cached entry is
|
|
11
|
+
* missing or closed — a running stub is used exactly like a real one.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
14
|
+
import { renderHook, act } from '@testing-library/react'
|
|
15
|
+
import type { EditorProject, VisualItem, VisualTrack } from '../../../schema'
|
|
16
|
+
import type { EngineStats, EngineStatus } from '../../../engine'
|
|
17
|
+
|
|
18
|
+
// jsdom implements neither `play()` nor `pause()`. Installed once, at module
|
|
19
|
+
// scope, rather than as a per-test spy: Testing Library's own `afterEach`
|
|
20
|
+
// cleanup unmounts the hook — and therefore runs the audio-lane teardown,
|
|
21
|
+
// which pauses every element — AFTER a per-test `restoreAllMocks()` would
|
|
22
|
+
// have put the unimplemented originals back.
|
|
23
|
+
Object.defineProperty(HTMLMediaElement.prototype, 'paused', {
|
|
24
|
+
configurable: true,
|
|
25
|
+
get(this: HTMLMediaElement & { __paused?: boolean }) { return this.__paused !== false },
|
|
26
|
+
})
|
|
27
|
+
HTMLMediaElement.prototype.play = function (this: HTMLMediaElement & { __paused?: boolean }) {
|
|
28
|
+
this.__paused = false
|
|
29
|
+
return Promise.resolve()
|
|
30
|
+
}
|
|
31
|
+
HTMLMediaElement.prototype.pause = function (this: HTMLMediaElement & { __paused?: boolean }) {
|
|
32
|
+
this.__paused = true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Shared: a controllable stub AudioContext ─────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface StubCtx {
|
|
38
|
+
state: 'running' | 'suspended'
|
|
39
|
+
outputLatency: number
|
|
40
|
+
baseLatency: number
|
|
41
|
+
resume: () => Promise<void>
|
|
42
|
+
destination: object
|
|
43
|
+
createMediaElementSource: () => { connect: () => void }
|
|
44
|
+
createGain: () => { gain: { value: number }; connect: () => void }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stubCtx(over: Partial<Pick<StubCtx, 'outputLatency' | 'baseLatency' | 'state'>> = {}): StubCtx {
|
|
48
|
+
const ctx: StubCtx = {
|
|
49
|
+
state: over.state ?? 'running',
|
|
50
|
+
outputLatency: over.outputLatency ?? 0,
|
|
51
|
+
baseLatency: over.baseLatency ?? 0,
|
|
52
|
+
resume: () => Promise.resolve(),
|
|
53
|
+
destination: {},
|
|
54
|
+
createMediaElementSource: () => ({ connect: () => {} }),
|
|
55
|
+
createGain: () => ({ gain: { value: 1 }, connect: () => {} }),
|
|
56
|
+
}
|
|
57
|
+
;(window as unknown as { __montajSharedCtx?: StubCtx }).__montajSharedCtx = ctx
|
|
58
|
+
return ctx
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function clearCtx() {
|
|
62
|
+
delete (window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// jsdom has no `AudioContext` at all — a default is needed so anything that
|
|
66
|
+
// hits `new AudioContext()` on a cold path doesn't ReferenceError. The tests
|
|
67
|
+
// below always seed `__montajSharedCtx` directly, so this default is only a
|
|
68
|
+
// safety net.
|
|
69
|
+
;(window as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
70
|
+
state = 'running'
|
|
71
|
+
outputLatency = 0
|
|
72
|
+
baseLatency = 0
|
|
73
|
+
destination = {}
|
|
74
|
+
resume() { return Promise.resolve() }
|
|
75
|
+
createMediaElementSource() { return { connect() {} } }
|
|
76
|
+
createGain() { return { gain: { value: 1 }, connect() {} } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── latencySeconds() — the pure helper ───────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
import { latencySeconds } from '../audio-context'
|
|
82
|
+
|
|
83
|
+
describe('latencySeconds', () => {
|
|
84
|
+
it('sums outputLatency and baseLatency', () => {
|
|
85
|
+
expect(latencySeconds({ outputLatency: 0.03, baseLatency: 0.005 })).toBeCloseTo(0.035, 10)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns 0.03 for the plan spec case (outputLatency=0.03, baseLatency=0)', () => {
|
|
89
|
+
expect(latencySeconds({ outputLatency: 0.03, baseLatency: 0 })).toBeCloseTo(0.03, 10)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('clamps a negative sum to zero', () => {
|
|
93
|
+
// Not observed in the wild, but a malformed stub or a broken driver could
|
|
94
|
+
// report a negative — the bridge must never emit a time greater than the
|
|
95
|
+
// clock's own now(), which is what a negative would produce.
|
|
96
|
+
expect(latencySeconds({ outputLatency: -0.5, baseLatency: 0 })).toBe(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('tolerates missing / NaN latency fields (non-Chromium contexts)', () => {
|
|
100
|
+
expect(latencySeconds({ outputLatency: NaN, baseLatency: 0.005 })).toBeCloseTo(0.005, 10)
|
|
101
|
+
expect(latencySeconds({ outputLatency: 0.03, baseLatency: NaN })).toBeCloseTo(0.03, 10)
|
|
102
|
+
expect(latencySeconds({ outputLatency: NaN, baseLatency: NaN })).toBe(0)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ── The <video> fallback bridge — useVideoPlayback ───────────────────────────
|
|
107
|
+
|
|
108
|
+
import { useVideoPlayback } from '../useVideoPlayback'
|
|
109
|
+
|
|
110
|
+
function videoClip(over: Partial<VisualItem> = {}): VisualItem {
|
|
111
|
+
return { id: 'a', type: 'video', src: '/a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, ...over } as VisualItem
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function videoProject(items: VisualItem[], track: Partial<VisualTrack> = {}): EditorProject {
|
|
115
|
+
return {
|
|
116
|
+
id: 'lat-video',
|
|
117
|
+
status: 'draft',
|
|
118
|
+
settings: { resolution: [1080, 1920] },
|
|
119
|
+
tracks: [{ id: 'trk-0', items, ...track }],
|
|
120
|
+
} as EditorProject
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A `<video>` element whose `currentTime` is a real read/write property. */
|
|
124
|
+
function fakeVideo(): HTMLVideoElement {
|
|
125
|
+
const el = document.createElement('video')
|
|
126
|
+
let t = 0
|
|
127
|
+
Object.defineProperty(el, 'currentTime', {
|
|
128
|
+
get: () => t,
|
|
129
|
+
set: (v: number) => { t = v },
|
|
130
|
+
configurable: true,
|
|
131
|
+
})
|
|
132
|
+
return el
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mountVideo(project: EditorProject, onTimeUpdate: (t: number) => void) {
|
|
136
|
+
const harness = renderHook(
|
|
137
|
+
({ p }: { p: EditorProject }) => useVideoPlayback(p, 0, onTimeUpdate, (path) => path),
|
|
138
|
+
{ initialProps: { p: project } },
|
|
139
|
+
)
|
|
140
|
+
const v0 = fakeVideo()
|
|
141
|
+
const v1 = fakeVideo()
|
|
142
|
+
harness.result.current.video0Ref.current = v0
|
|
143
|
+
harness.result.current.video1Ref.current = v1
|
|
144
|
+
harness.rerender({ p: { ...project } })
|
|
145
|
+
return { ...harness, v0, v1 }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe('<video> fallback bridge — useVideoPlayback', () => {
|
|
149
|
+
let playSpy: ReturnType<typeof vi.spyOn>
|
|
150
|
+
let pauseSpy: ReturnType<typeof vi.spyOn>
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
|
|
153
|
+
pauseSpy = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
|
154
|
+
})
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
playSpy.mockRestore()
|
|
157
|
+
pauseSpy.mockRestore()
|
|
158
|
+
vi.restoreAllMocks()
|
|
159
|
+
clearCtx()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('emits now() - outputLatency on handleTimeUpdate (spec: 0.03 → t-0.03)', () => {
|
|
163
|
+
stubCtx({ outputLatency: 0.03, baseLatency: 0 })
|
|
164
|
+
const onTimeUpdate = vi.fn<(t: number) => void>()
|
|
165
|
+
const { result, v0 } = mountVideo(videoProject([videoClip()]), onTimeUpdate)
|
|
166
|
+
onTimeUpdate.mockClear()
|
|
167
|
+
|
|
168
|
+
v0.currentTime = 2 // → project time = clip.start + (2 - inPoint) = 2
|
|
169
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
170
|
+
|
|
171
|
+
// The last emit is the main handleTimeUpdate emit at the tail of the
|
|
172
|
+
// handler; earlier writes on the pre-cut path fold into it.
|
|
173
|
+
const emitted = onTimeUpdate.mock.calls[onTimeUpdate.mock.calls.length - 1]?.[0]
|
|
174
|
+
expect(emitted).toBeCloseTo(2 - 0.03, 6)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('re-reads latency LIVE across ticks (device switch)', () => {
|
|
178
|
+
const ctx = stubCtx({ outputLatency: 0.03, baseLatency: 0 })
|
|
179
|
+
const onTimeUpdate = vi.fn<(t: number) => void>()
|
|
180
|
+
const { result, v0 } = mountVideo(videoProject([videoClip()]), onTimeUpdate)
|
|
181
|
+
|
|
182
|
+
v0.currentTime = 1
|
|
183
|
+
onTimeUpdate.mockClear()
|
|
184
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
185
|
+
expect(onTimeUpdate.mock.calls[onTimeUpdate.mock.calls.length - 1]?.[0]).toBeCloseTo(1 - 0.03, 6)
|
|
186
|
+
|
|
187
|
+
// Device switch mid-session — a bluetooth output arrives with a higher
|
|
188
|
+
// buffer. Same ctx object, new field value: the bridge picks it up on the
|
|
189
|
+
// very next tick, no re-mount required.
|
|
190
|
+
ctx.outputLatency = 0.2
|
|
191
|
+
v0.currentTime = 2
|
|
192
|
+
onTimeUpdate.mockClear()
|
|
193
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
194
|
+
expect(onTimeUpdate.mock.calls[onTimeUpdate.mock.calls.length - 1]?.[0]).toBeCloseTo(2 - 0.2, 6)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('clamps the emitted time at zero when latency exceeds the current time', () => {
|
|
198
|
+
stubCtx({ outputLatency: 0.5, baseLatency: 0 })
|
|
199
|
+
const onTimeUpdate = vi.fn<(t: number) => void>()
|
|
200
|
+
const { result, v0 } = mountVideo(videoProject([videoClip()]), onTimeUpdate)
|
|
201
|
+
|
|
202
|
+
v0.currentTime = 0.1 // project time ≈ 0.1, latency 0.5 → would go negative
|
|
203
|
+
onTimeUpdate.mockClear()
|
|
204
|
+
act(() => { result.current.handleTimeUpdate() })
|
|
205
|
+
expect(onTimeUpdate.mock.calls[onTimeUpdate.mock.calls.length - 1]?.[0]).toBe(0)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ── The engine bridge — useEnginePlayback ────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
// The engine module owns three WebCodecs paths jsdom cannot execute; the fake
|
|
212
|
+
// used here reproduces the seam this hook actually observes (emit, transport
|
|
213
|
+
// transitions, seek/pause/play counters) — the same pattern as the neighboring
|
|
214
|
+
// `useEnginePlayback.test.tsx`.
|
|
215
|
+
interface FakeEngine {
|
|
216
|
+
deps: {
|
|
217
|
+
onTime?: (t: number) => void
|
|
218
|
+
onStatusChange?: (s: EngineStatus) => void
|
|
219
|
+
onError?: (m: string) => void
|
|
220
|
+
startProjectS?: number
|
|
221
|
+
fileUrl: (p: string) => string
|
|
222
|
+
}
|
|
223
|
+
transport: EngineStatus['transport']
|
|
224
|
+
now: number
|
|
225
|
+
seeks: number[]
|
|
226
|
+
emit(t: number): void
|
|
227
|
+
setTransport(t: EngineStatus['transport']): void
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const engines = vi.hoisted(() => [] as unknown[])
|
|
231
|
+
|
|
232
|
+
vi.mock('../../../engine', async (importOriginal) => {
|
|
233
|
+
const actual = await importOriginal<typeof import('../../../engine')>()
|
|
234
|
+
return {
|
|
235
|
+
...actual,
|
|
236
|
+
createEngine: (project: EditorProject, deps: FakeEngine['deps']) => {
|
|
237
|
+
const status = (): EngineStatus => ({
|
|
238
|
+
transport: engine.transport,
|
|
239
|
+
picture: 'video',
|
|
240
|
+
clipId: null,
|
|
241
|
+
seeking: false,
|
|
242
|
+
clock: 'fallback',
|
|
243
|
+
})
|
|
244
|
+
const engine: FakeEngine & {
|
|
245
|
+
attach: (c: HTMLCanvasElement | null) => void
|
|
246
|
+
play: () => void
|
|
247
|
+
pause: () => void
|
|
248
|
+
seek: (t: number) => void
|
|
249
|
+
setRate: (r: number) => void
|
|
250
|
+
updateProject: (p: EditorProject) => void
|
|
251
|
+
status: () => EngineStatus
|
|
252
|
+
clock: { now: () => number; playing: boolean; kind: 'audio' | 'fallback' }
|
|
253
|
+
stats: () => EngineStats
|
|
254
|
+
dispose: () => void
|
|
255
|
+
} = {
|
|
256
|
+
deps,
|
|
257
|
+
transport: 'paused',
|
|
258
|
+
now: deps.startProjectS ?? 0,
|
|
259
|
+
seeks: [],
|
|
260
|
+
emit(t: number) { engine.now = t; deps.onTime?.(t) },
|
|
261
|
+
setTransport(t) { engine.transport = t; deps.onStatusChange?.(status()) },
|
|
262
|
+
attach: () => {},
|
|
263
|
+
play: () => { engine.transport = 'playing'; deps.onStatusChange?.(status()) },
|
|
264
|
+
pause: () => { engine.transport = 'paused'; deps.onStatusChange?.(status()) },
|
|
265
|
+
seek: (t: number) => { engine.seeks.push(t); engine.now = t },
|
|
266
|
+
setRate: () => {},
|
|
267
|
+
updateProject: () => {},
|
|
268
|
+
status,
|
|
269
|
+
clock: {
|
|
270
|
+
get now() { return () => engine.now },
|
|
271
|
+
get playing() { return engine.transport === 'playing' },
|
|
272
|
+
kind: 'fallback' as const,
|
|
273
|
+
} as unknown as { now: () => number; playing: boolean; kind: 'audio' | 'fallback' },
|
|
274
|
+
stats: () => ({ fps: 24, dropped: 0, buffered: 0, clock: 'fallback' }),
|
|
275
|
+
dispose: () => {},
|
|
276
|
+
}
|
|
277
|
+
engines.push(engine)
|
|
278
|
+
return engine
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// Import AFTER the mock declaration for readability — vi.mock is hoisted.
|
|
284
|
+
import { useEnginePlayback } from '../useEnginePlayback'
|
|
285
|
+
|
|
286
|
+
function engineProject(): EditorProject {
|
|
287
|
+
return {
|
|
288
|
+
id: 'lat-engine',
|
|
289
|
+
status: 'draft',
|
|
290
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
291
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', proxySrc: 'a_proxy.mp4', start: 0, end: 10 }] }],
|
|
292
|
+
} as EditorProject
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function setupEngine() {
|
|
296
|
+
const onTimeUpdate = vi.fn<(t: number) => void>()
|
|
297
|
+
const view = renderHook(
|
|
298
|
+
({ t }: { t: number }) => useEnginePlayback(engineProject(), t, onTimeUpdate, (p) => p),
|
|
299
|
+
{ initialProps: { t: 0 } },
|
|
300
|
+
)
|
|
301
|
+
const engine = engines[engines.length - 1] as FakeEngine
|
|
302
|
+
return { view, engine, onTimeUpdate }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
describe('engine bridge — useEnginePlayback.emitTime', () => {
|
|
306
|
+
beforeEach(() => { engines.length = 0 })
|
|
307
|
+
afterEach(() => { clearCtx() })
|
|
308
|
+
|
|
309
|
+
it('emits now() - outputLatency during playback (spec: 0.03 → t-0.03)', () => {
|
|
310
|
+
stubCtx({ outputLatency: 0.03, baseLatency: 0 })
|
|
311
|
+
const { engine, onTimeUpdate } = setupEngine()
|
|
312
|
+
|
|
313
|
+
act(() => { engine.setTransport('playing') })
|
|
314
|
+
onTimeUpdate.mockClear()
|
|
315
|
+
act(() => { engine.emit(1.5) })
|
|
316
|
+
|
|
317
|
+
expect(onTimeUpdate).toHaveBeenCalledWith(expect.closeTo(1.5 - 0.03, 6))
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('sums outputLatency and baseLatency during playback', () => {
|
|
321
|
+
stubCtx({ outputLatency: 0.03, baseLatency: 0.005 })
|
|
322
|
+
const { engine, onTimeUpdate } = setupEngine()
|
|
323
|
+
|
|
324
|
+
act(() => { engine.setTransport('playing') })
|
|
325
|
+
onTimeUpdate.mockClear()
|
|
326
|
+
act(() => { engine.emit(1.5) })
|
|
327
|
+
|
|
328
|
+
expect(onTimeUpdate).toHaveBeenCalledWith(expect.closeTo(1.5 - 0.035, 6))
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('re-reads latency LIVE across ticks (device switch)', () => {
|
|
332
|
+
const ctx = stubCtx({ outputLatency: 0.03, baseLatency: 0 })
|
|
333
|
+
const { engine, onTimeUpdate } = setupEngine()
|
|
334
|
+
|
|
335
|
+
act(() => { engine.setTransport('playing') })
|
|
336
|
+
onTimeUpdate.mockClear()
|
|
337
|
+
act(() => { engine.emit(1.0) })
|
|
338
|
+
expect(onTimeUpdate).toHaveBeenLastCalledWith(expect.closeTo(1.0 - 0.03, 6))
|
|
339
|
+
|
|
340
|
+
ctx.outputLatency = 0.2
|
|
341
|
+
act(() => { engine.emit(2.0) })
|
|
342
|
+
expect(onTimeUpdate).toHaveBeenLastCalledWith(expect.closeTo(2.0 - 0.2, 6))
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('does NOT compensate when the transport is paused (no seek/scrub-display regression)', () => {
|
|
346
|
+
// The paused path emits via `scheduler.apply` on seek-land with the exact
|
|
347
|
+
// seek target. Compensating that would show the user 4.97 after they
|
|
348
|
+
// scrubbed to 5.00 — precisely the "seek/scrub display regression" the
|
|
349
|
+
// task tells us to avoid.
|
|
350
|
+
stubCtx({ outputLatency: 0.03, baseLatency: 0 })
|
|
351
|
+
const { engine, onTimeUpdate } = setupEngine()
|
|
352
|
+
// Transport stays 'paused' (its default).
|
|
353
|
+
onTimeUpdate.mockClear()
|
|
354
|
+
|
|
355
|
+
act(() => { engine.emit(5.0) })
|
|
356
|
+
expect(onTimeUpdate).toHaveBeenLastCalledWith(5.0)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('clamps the emitted time at zero when latency exceeds the current time', () => {
|
|
360
|
+
stubCtx({ outputLatency: 0.5, baseLatency: 0 })
|
|
361
|
+
const { engine, onTimeUpdate } = setupEngine()
|
|
362
|
+
|
|
363
|
+
act(() => { engine.setTransport('playing') })
|
|
364
|
+
onTimeUpdate.mockClear()
|
|
365
|
+
act(() => { engine.emit(0.1) })
|
|
366
|
+
|
|
367
|
+
expect(onTimeUpdate).toHaveBeenLastCalledWith(0)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// ── syncAudioTracks callsites OUTSIDE emitTime get RAW time, not painted ─────
|
|
372
|
+
//
|
|
373
|
+
// `emitTime`'s own direct `syncAudioTracks(t, playing)` call was never broken
|
|
374
|
+
// — it already hands the raw `t` parameter through. The regression these
|
|
375
|
+
// guard is the OTHER two callsites (the "audio lane added mid-session" effect
|
|
376
|
+
// and the "transport transition" effect), which used to reuse
|
|
377
|
+
// `lastEmittedRef.current` — the PAINTED, latency-compensated mirror — and
|
|
378
|
+
// would double-lag a newly added lane, or a play/pause re-sync, by
|
|
379
|
+
// `latencySeconds`.
|
|
380
|
+
|
|
381
|
+
describe('engine bridge — syncAudioTracks callsites outside emitTime', () => {
|
|
382
|
+
let RealAudio: typeof Audio
|
|
383
|
+
const audioEls: HTMLAudioElement[] = []
|
|
384
|
+
|
|
385
|
+
beforeEach(() => {
|
|
386
|
+
engines.length = 0
|
|
387
|
+
audioEls.length = 0
|
|
388
|
+
RealAudio = window.Audio
|
|
389
|
+
;(window as unknown as { Audio: unknown }).Audio = class extends RealAudio {
|
|
390
|
+
constructor(src?: string) { super(src); audioEls.push(this as unknown as HTMLAudioElement) }
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
afterEach(() => {
|
|
394
|
+
;(window as unknown as { Audio: unknown }).Audio = RealAudio
|
|
395
|
+
clearCtx()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
function projectWithAudio(includeAudio: boolean): EditorProject {
|
|
399
|
+
return {
|
|
400
|
+
...engineProject(),
|
|
401
|
+
audio: {
|
|
402
|
+
tracks: includeAudio
|
|
403
|
+
? [{ id: 'a0', src: 'music.mp3', start: 0, end: 100, inPoint: 0, volume: 1 } as never]
|
|
404
|
+
: [],
|
|
405
|
+
},
|
|
406
|
+
} as EditorProject
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
it('a lane added mid-session is placed at the RAW playhead, not the latency-painted one', () => {
|
|
410
|
+
stubCtx({ outputLatency: 1, baseLatency: 0 })
|
|
411
|
+
const onTimeUpdate = vi.fn<(t: number) => void>()
|
|
412
|
+
const view = renderHook(
|
|
413
|
+
({ p }: { p: EditorProject }) => useEnginePlayback(p, 0, onTimeUpdate, (path) => path),
|
|
414
|
+
{ initialProps: { p: projectWithAudio(false) } },
|
|
415
|
+
)
|
|
416
|
+
const engine = engines[engines.length - 1] as FakeEngine
|
|
417
|
+
|
|
418
|
+
act(() => { engine.setTransport('playing') })
|
|
419
|
+
act(() => { engine.emit(10) }) // raw=10, painted=9 (1s latency) — mirrors diverge here
|
|
420
|
+
|
|
421
|
+
// Add the audio track mid-session, WITHOUT another emit: the effect must
|
|
422
|
+
// read the mirrored RAW time, not the painted one.
|
|
423
|
+
act(() => { view.rerender({ p: projectWithAudio(true) }) })
|
|
424
|
+
|
|
425
|
+
expect(audioEls).toHaveLength(1)
|
|
426
|
+
// trackTime = playhead - start(0) + inPoint(0): 10 if fixed, 9 if the
|
|
427
|
+
// painted mirror regresses back in.
|
|
428
|
+
expect(audioEls[0].currentTime).toBeCloseTo(10, 6)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('a transport transition re-syncs lanes to the RAW playhead, not the latency-painted one', () => {
|
|
432
|
+
stubCtx({ outputLatency: 1, baseLatency: 0 })
|
|
433
|
+
const onTimeUpdate = vi.fn<(t: number) => void>()
|
|
434
|
+
renderHook(
|
|
435
|
+
({ p }: { p: EditorProject }) => useEnginePlayback(p, 0, onTimeUpdate, (path) => path),
|
|
436
|
+
{ initialProps: { p: projectWithAudio(true) } },
|
|
437
|
+
)
|
|
438
|
+
const engine = engines[engines.length - 1] as FakeEngine
|
|
439
|
+
|
|
440
|
+
act(() => { engine.setTransport('playing') })
|
|
441
|
+
act(() => { engine.emit(10) }) // raw=10, painted=9; the direct emitTime call already seeks correctly
|
|
442
|
+
|
|
443
|
+
// Knock the element off both candidate targets so the NEXT resync — from
|
|
444
|
+
// the transport-transition effect alone, no intervening emit — is the
|
|
445
|
+
// only thing that can put it back.
|
|
446
|
+
audioEls[0].currentTime = 50
|
|
447
|
+
act(() => { engine.setTransport('paused') })
|
|
448
|
+
|
|
449
|
+
expect(audioEls[0].currentTime).toBeCloseTo(10, 6)
|
|
450
|
+
})
|
|
451
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** SP3 fix B2 — capability gate + failure suppression. */
|
|
2
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
gateProxy,
|
|
5
|
+
isProxyUsable,
|
|
6
|
+
markProxyFailed,
|
|
7
|
+
proxyPlaybackSupported,
|
|
8
|
+
__clearFailedProxiesForTests,
|
|
9
|
+
__setProxySupportForTests,
|
|
10
|
+
} from '../proxySupport'
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
__setProxySupportForTests(null)
|
|
14
|
+
__clearFailedProxiesForTests()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('proxyPlaybackSupported', () => {
|
|
18
|
+
it('reports supported under the test-setup canPlayType stub', () => {
|
|
19
|
+
__setProxySupportForTests(null)
|
|
20
|
+
expect(proxyPlaybackSupported()).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('probes for the codec the proxies are actually encoded in', () => {
|
|
24
|
+
// PROXY_MIME is module-private, so pin it where it is observable: the
|
|
25
|
+
// string handed to the capability probe. Without this, nothing in the
|
|
26
|
+
// suite ties the gate to the encoder — `lib/proxy.py` could move to a new
|
|
27
|
+
// codec and every test here would still pass while the editor silently
|
|
28
|
+
// judged real proxies undecodable (or worse, decodable on stale evidence).
|
|
29
|
+
__setProxySupportForTests(null)
|
|
30
|
+
const seen: string[] = []
|
|
31
|
+
const ms = (globalThis as { MediaSource?: { isTypeSupported?: (t: string) => boolean } }).MediaSource
|
|
32
|
+
const realIsTypeSupported = ms?.isTypeSupported
|
|
33
|
+
const realCanPlayType = HTMLMediaElement.prototype.canPlayType
|
|
34
|
+
if (ms) ms.isTypeSupported = (t: string) => { seen.push(t); return true }
|
|
35
|
+
HTMLMediaElement.prototype.canPlayType = function (t: string) { seen.push(t); return 'probably' }
|
|
36
|
+
try {
|
|
37
|
+
proxyPlaybackSupported()
|
|
38
|
+
} finally {
|
|
39
|
+
if (ms) ms.isTypeSupported = realIsTypeSupported
|
|
40
|
+
HTMLMediaElement.prototype.canPlayType = realCanPlayType
|
|
41
|
+
}
|
|
42
|
+
expect(seen).toHaveLength(1)
|
|
43
|
+
expect(seen[0]).toBe('video/mp4; codecs="avc1.640028, opus"')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('gateProxy', () => {
|
|
48
|
+
const item = { id: 'c1', src: '/a/orig.mov', proxySrc: '/a/orig_proxy_hable1.mp4' }
|
|
49
|
+
|
|
50
|
+
it('passes the item through untouched when the proxy is usable', () => {
|
|
51
|
+
__setProxySupportForTests(true)
|
|
52
|
+
expect(gateProxy(item)).toBe(item) // same reference — no needless copies
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('strips proxySrc when playback is unsupported, without mutating the input', () => {
|
|
56
|
+
__setProxySupportForTests(false)
|
|
57
|
+
const gated = gateProxy(item)
|
|
58
|
+
expect(gated.proxySrc).toBeUndefined()
|
|
59
|
+
expect(gated.src).toBe('/a/orig.mov')
|
|
60
|
+
expect(item.proxySrc).toBe('/a/orig_proxy_hable1.mp4') // input untouched
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('strips proxySrc after the proxy is marked failed, even when supported', () => {
|
|
64
|
+
__setProxySupportForTests(true)
|
|
65
|
+
markProxyFailed(item.proxySrc)
|
|
66
|
+
expect(isProxyUsable(item.proxySrc)).toBe(false)
|
|
67
|
+
expect(gateProxy(item).proxySrc).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('leaves proxy-less items alone regardless of support', () => {
|
|
71
|
+
__setProxySupportForTests(false)
|
|
72
|
+
const plain: { id: string; src: string; proxySrc?: string } = { id: 'c2', src: '/b/orig.mov' }
|
|
73
|
+
expect(gateProxy(plain)).toBe(plain)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -5,13 +5,34 @@ describe('videoTransformContainerStyle', () => {
|
|
|
5
5
|
it('identity transform → empty style (no-op)', () => {
|
|
6
6
|
expect(videoTransformContainerStyle({})).toEqual({})
|
|
7
7
|
expect(videoTransformContainerStyle({ scale: 1, offsetX: 0, offsetY: 0 })).toEqual({})
|
|
8
|
+
// Identity spelled per-axis is still identity.
|
|
9
|
+
expect(videoTransformContainerStyle({ scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 })).toEqual({})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('a single non-identity axis defeats the no-op early return', () => {
|
|
13
|
+
expect(videoTransformContainerStyle({ scaleX: 2 }).transform).toBe('translate(0%, 0%) scale(2, 1)')
|
|
14
|
+
expect(videoTransformContainerStyle({ scaleY: 2 }).transform).toBe('translate(0%, 0%) scale(1, 2)')
|
|
8
15
|
})
|
|
9
16
|
|
|
10
17
|
it('scale + offset → translate then scale, origin center', () => {
|
|
11
18
|
const s = videoTransformContainerStyle({ scale: 2, offsetX: 10, offsetY: -5 })
|
|
12
|
-
|
|
19
|
+
// Uniform `scale` fills BOTH arguments — same box the one-argument form drew.
|
|
20
|
+
expect(s.transform).toBe('translate(10%, -5%) scale(2, 2)')
|
|
21
|
+
expect(s.transformOrigin).toBe('center center')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('per-axis scale emits each axis independently', () => {
|
|
25
|
+
const s = videoTransformContainerStyle({ scaleX: 1.5, scaleY: 0.5, offsetX: 10, offsetY: -5 })
|
|
26
|
+
expect(s.transform).toBe('translate(10%, -5%) scale(1.5, 0.5)')
|
|
13
27
|
expect(s.transformOrigin).toBe('center center')
|
|
14
28
|
})
|
|
29
|
+
|
|
30
|
+
it('per-axis wins over the uniform `scale`, one axis at a time', () => {
|
|
31
|
+
expect(videoTransformContainerStyle({ scale: 3, scaleY: 0.25 }).transform)
|
|
32
|
+
.toBe('translate(0%, 0%) scale(3, 0.25)')
|
|
33
|
+
expect(videoTransformContainerStyle({ scale: 3, scaleX: 0.25 }).transform)
|
|
34
|
+
.toBe('translate(0%, 0%) scale(0.25, 3)')
|
|
35
|
+
})
|
|
15
36
|
})
|
|
16
37
|
|
|
17
38
|
describe('videoTransformBoxPct', () => {
|
|
@@ -31,4 +52,11 @@ describe('videoTransformBoxPct', () => {
|
|
|
31
52
|
expect(videoTransformBoxPct({ scale: 2, offsetX: 10, offsetY: -5 }))
|
|
32
53
|
.toEqual({ width: 200, height: 200, left: -40, top: -55 })
|
|
33
54
|
})
|
|
55
|
+
|
|
56
|
+
// Inherited for free: geometryFor resolves scaleX/scaleY, toCssBoxPct takes
|
|
57
|
+
// width/left from the X scale and height/top from the Y scale.
|
|
58
|
+
it('per-axis scale sizes width and height independently', () => {
|
|
59
|
+
expect(videoTransformBoxPct({ scaleX: 2, scaleY: 0.5 }))
|
|
60
|
+
.toEqual({ width: 200, height: 50, left: -50, top: 25 })
|
|
61
|
+
})
|
|
34
62
|
})
|