@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,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T6 — the WebCodecs engine's React seam.
|
|
3
|
+
*
|
|
4
|
+
* `useVideoPlayback` is this module's specification. It answers the same
|
|
5
|
+
* questions `PreviewPlayer` asks today — *is it playing, what is on track 0,
|
|
6
|
+
* what are the overlay tracks, toggle playback* — but every answer comes from
|
|
7
|
+
* `engine/`'s single tick instead of two `<video>` slots and three rAF clocks.
|
|
8
|
+
*
|
|
9
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
10
|
+
* WHAT THIS HOOK OWNS
|
|
11
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
12
|
+
* 1. **Engine lifecycle.** Created once per project IDENTITY, disposed on
|
|
13
|
+
* unmount or identity change; project EDITS go through `updateProject`
|
|
14
|
+
* (which is also how a `preparing` clip resolves when SSE delivers its
|
|
15
|
+
* proxy). Engine eligibility is NOT re-evaluated here — that is the call
|
|
16
|
+
* site's job (`PreviewPlayer`'s `useEngineMode`), per plan decision 2.
|
|
17
|
+
* 2. **The clock bridge** — see `emitTime` and the scrub effect below.
|
|
18
|
+
* 3. **The gesture anchors.** Both of the legacy hook's
|
|
19
|
+
* `resumeAudioContextFromGesture` call sites (`togglePlay` and the Space
|
|
20
|
+
* keydown listener) live *inside* `useVideoPlayback`, so engine mode would
|
|
21
|
+
* silently lose them. They are re-implemented here against the shared
|
|
22
|
+
* `audio-context.ts` module — the engine needs the resume MORE than the
|
|
23
|
+
* legacy path does, because a suspended context stalls its master clock
|
|
24
|
+
* and therefore stalls painting, not just audio.
|
|
25
|
+
* 4. **The audio lanes.** `project.audio.tracks` stay `<audio>` elements
|
|
26
|
+
* (plan decision 5) with the legacy element/GainNode lifecycle kept
|
|
27
|
+
* verbatim; only the per-tick sync arithmetic is rewritten, onto
|
|
28
|
+
* `timeline-core`'s `audioWindow`, and driven from the engine tick rather
|
|
29
|
+
* than from a React effect on `currentTime`.
|
|
30
|
+
*
|
|
31
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
32
|
+
* WHAT IT DELIBERATELY DOES NOT OWN
|
|
33
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
34
|
+
* - **Video-item volume, including >1.0.** The legacy hook routes each
|
|
35
|
+
* `<video>` slot through a GainNode to get amplification. The engine has no
|
|
36
|
+
* element to route: `createMasterClock` takes the item's `volume`/`muted`
|
|
37
|
+
* and scales the PCM at ring-enqueue time (T4), reached from
|
|
38
|
+
* `engine/index.ts`'s `SourceRequest` → `request.item.volume`. The TRACK's
|
|
39
|
+
* volume/mute ride the same path: the scheduler folds them into the request
|
|
40
|
+
* item (`withTrackAudio`) before the host ever sees it. Nothing to thread
|
|
41
|
+
* here either way; adding a second volume path would be the duplication the
|
|
42
|
+
* divergence registry exists to prevent.
|
|
43
|
+
* - **`<video>` slot mechanics.** No refs, no slot swap, no `onError` proxy
|
|
44
|
+
* fallback (`proxySupport.ts` gates the LEGACY player; the engine's own
|
|
45
|
+
* gate is `engine/eligibility.ts`, and a proxy that fails mid-session
|
|
46
|
+
* routes to the scheduler's `preparing` picture, not to a src downgrade).
|
|
47
|
+
*/
|
|
48
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
49
|
+
import { audioWindow } from '@bycrux/timeline-core'
|
|
50
|
+
import {
|
|
51
|
+
createEngine,
|
|
52
|
+
track0VideoItems,
|
|
53
|
+
type AcquiredDemux,
|
|
54
|
+
type Engine,
|
|
55
|
+
type EngineStats,
|
|
56
|
+
type EngineStatus,
|
|
57
|
+
} from '../../engine'
|
|
58
|
+
import { getSharedAudioContext, latencySeconds, peekSharedAudioContext, resumeAudioContextFromGesture } from './audio-context'
|
|
59
|
+
import type { EditorProject as Project, VisualItem } from '../../schema'
|
|
60
|
+
import { enabledTrackItems } from '../timeline/timeline-model'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The legacy scrub effect's dead-zone (`Math.abs(currentTime -
|
|
64
|
+
* lastTimeRef.current) < 0.05`) — the mechanism, the constant and the reason
|
|
65
|
+
* are all inherited: below it, a difference is the playhead's own rounding
|
|
66
|
+
* coming back around, not a user asking to go somewhere.
|
|
67
|
+
*/
|
|
68
|
+
export const SCRUB_DEAD_ZONE_S = 0.05
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The legacy `syncAudioTracks` re-seek threshold, kept exactly. It was chosen
|
|
72
|
+
* against `<video>`-derived time; the engine's measured A/V error (±4ms) is an
|
|
73
|
+
* order of magnitude inside it, so it stays a *drift* correction rather than
|
|
74
|
+
* becoming a per-tick re-seek.
|
|
75
|
+
*/
|
|
76
|
+
export const AUDIO_SYNC_THRESHOLD_S = 0.3
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How many engine-emitted playhead values to remember for echo suppression.
|
|
80
|
+
* 120 ≈ two seconds at 60Hz — comfortably longer than any plausible React
|
|
81
|
+
* render lag, short enough that a value in it is always within ~2s of the
|
|
82
|
+
* playhead. See `isEcho`.
|
|
83
|
+
*/
|
|
84
|
+
const EMIT_HISTORY = 120
|
|
85
|
+
|
|
86
|
+
const IDLE_STATUS: EngineStatus = {
|
|
87
|
+
transport: 'idle',
|
|
88
|
+
picture: 'black',
|
|
89
|
+
clipId: null,
|
|
90
|
+
seeking: false,
|
|
91
|
+
clock: 'fallback',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Slave one audio lane element to the transport rate R, pitch-corrected.
|
|
96
|
+
*
|
|
97
|
+
* The engine playhead advances R× (the J/K/L shuttle's fast-forward), so a lane
|
|
98
|
+
* playing at `playbackRate = R` keeps its `currentTime` in step with the picture
|
|
99
|
+
* — music/VO speed up together instead of drifting. `preservesPitch` stops the
|
|
100
|
+
* speed-up from chipmunking; the vendor-prefixed variants are set best-effort
|
|
101
|
+
* for engines that only understand those.
|
|
102
|
+
*/
|
|
103
|
+
function applyLaneRate(el: HTMLAudioElement, rate: number): void {
|
|
104
|
+
el.playbackRate = rate
|
|
105
|
+
el.preservesPitch = true
|
|
106
|
+
const vendor = el as unknown as { mozPreservesPitch?: boolean; webkitPreservesPitch?: boolean }
|
|
107
|
+
if ('mozPreservesPitch' in vendor) vendor.mozPreservesPitch = true
|
|
108
|
+
if ('webkitPreservesPitch' in vendor) vendor.webkitPreservesPitch = true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What `PreviewPlayer` consumes, minus the `<video>`-slot keys (refs, slot
|
|
113
|
+
* index, `onTimeUpdate`/`onPause`/`onEnded`/`onError` handlers) — in engine
|
|
114
|
+
* mode those elements do not exist, and the surface renders `EngineSurface`
|
|
115
|
+
* instead of the two slots. Two of the survey's slot-adjacent keys are kept
|
|
116
|
+
* because `PreviewPlayer` reads them outside the slot JSX or would otherwise
|
|
117
|
+
* need a second code path; both are documented at their definition below.
|
|
118
|
+
*/
|
|
119
|
+
export interface EnginePlayback {
|
|
120
|
+
/** `transport === 'playing'`. Drives the play-button overlay and `OverlayItemsLayer`. */
|
|
121
|
+
isPlaying: boolean
|
|
122
|
+
/**
|
|
123
|
+
* **Inert.** The legacy hook exposes this so the `<video>` elements' own
|
|
124
|
+
* `onPlay` can push the browser's opinion of playback back into React. The
|
|
125
|
+
* engine has no element with an opinion — the transport is authoritative and
|
|
126
|
+
* flows the other way — so this is a documented no-op, kept only so the
|
|
127
|
+
* shared surface's prop shape is one interface rather than two.
|
|
128
|
+
*/
|
|
129
|
+
setIsPlaying: (value: boolean | ((prev: boolean) => boolean)) => void
|
|
130
|
+
/**
|
|
131
|
+
* The `showVideo` analog: `picture === 'video'`. The legacy hook uses it to
|
|
132
|
+
* zero a slot's opacity through a gap; the engine paints its own black, so
|
|
133
|
+
* this is informational for the surface (and for T7's HUD) rather than
|
|
134
|
+
* load-bearing.
|
|
135
|
+
*/
|
|
136
|
+
showVideo: boolean
|
|
137
|
+
/** Gesture-anchored play/pause. Resumes the shared AudioContext first. */
|
|
138
|
+
togglePlay: () => void
|
|
139
|
+
/**
|
|
140
|
+
* The J/K/L shuttle's live transport rate. Pushes R onto the engine (project
|
|
141
|
+
* time advances R×, audio pitch-corrected by the engine's own time-stretch)
|
|
142
|
+
* AND onto every `<audio>` lane's `playbackRate`, so music/VO fast-forward in
|
|
143
|
+
* lockstep. Reset to 1 by the shuttle on reverse/stop.
|
|
144
|
+
*/
|
|
145
|
+
setRate: (rate: number) => void
|
|
146
|
+
/** No track-0 video items — the legacy `isCanvasProject`, same predicate. */
|
|
147
|
+
isCanvasProject: boolean
|
|
148
|
+
/** Track-0 video items, start-sorted. Identical to the legacy `clips` memo. */
|
|
149
|
+
clips: VisualItem[]
|
|
150
|
+
/** Everything else on track 0 (images, overlays). */
|
|
151
|
+
tracks0NonVideo: VisualItem[]
|
|
152
|
+
/** `project.tracks[1..]`. */
|
|
153
|
+
overlayTracks: VisualItem[][]
|
|
154
|
+
/** Live engine status — `EngineSurface` renders the picture state from it. */
|
|
155
|
+
status: EngineStatus
|
|
156
|
+
/** Canvas ref callback. Stable identity, so React never re-attaches on a re-render. */
|
|
157
|
+
attachCanvas: (canvas: HTMLCanvasElement | null) => void
|
|
158
|
+
/**
|
|
159
|
+
* T7's debug HUD reads engine stats through this rather than through React
|
|
160
|
+
* state, and polls it on its own timer — `status` above only changes on a
|
|
161
|
+
* genuine transport/picture/clip/clock transition (`scheduler.ts`'s
|
|
162
|
+
* `publish`), so fps/buffer, which move every painted frame, would sit
|
|
163
|
+
* stale between those events if the HUD keyed off `status` instead. Stable
|
|
164
|
+
* identity, like `attachCanvas`. `null` whenever there is no live engine
|
|
165
|
+
* (between project identities, or after dispose).
|
|
166
|
+
*/
|
|
167
|
+
getStats: () => EngineStats | null
|
|
168
|
+
/**
|
|
169
|
+
* The engine's shared demux LRU, exposed for the audible drag-scrub source
|
|
170
|
+
* (`../../engine/scrub-source.ts`) — it composites over this graph rather
|
|
171
|
+
* than driving the master clock's ring, and pins/releases through the same
|
|
172
|
+
* LRU the scheduler reads from (`../../engine/index.ts`'s `acquirePinnedDemux`).
|
|
173
|
+
* Stable identity, like `getStats`.
|
|
174
|
+
*/
|
|
175
|
+
acquireDemux: (src: string) => Promise<AcquiredDemux>
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function useEnginePlayback(
|
|
179
|
+
project: Project,
|
|
180
|
+
currentTime: number,
|
|
181
|
+
onTimeUpdate: (t: number) => void,
|
|
182
|
+
fileUrl: (path: string) => string,
|
|
183
|
+
): EnginePlayback {
|
|
184
|
+
// ── Derived collections (the legacy memos, verbatim) ──────────────────────
|
|
185
|
+
// `track0VideoItems` IS the legacy `clips` memo, lifted into the scheduler so
|
|
186
|
+
// one definition serves both the engine's tick and this surface.
|
|
187
|
+
const clips = useMemo(() => track0VideoItems(project), [project])
|
|
188
|
+
const tracks0NonVideo = useMemo(() => (enabledTrackItems(project)[0] ?? []).filter(c => c.type !== 'video'), [project])
|
|
189
|
+
const overlayTracks = useMemo(() => enabledTrackItems(project).slice(1), [project])
|
|
190
|
+
const isCanvasProject = clips.length === 0
|
|
191
|
+
|
|
192
|
+
const [status, setStatus] = useState<EngineStatus>(IDLE_STATUS)
|
|
193
|
+
const transportRef = useRef<EngineStatus['transport']>('idle')
|
|
194
|
+
|
|
195
|
+
const engineRef = useRef<Engine | null>(null)
|
|
196
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
197
|
+
|
|
198
|
+
// Latest-value refs for everything the engine's construction-time callbacks
|
|
199
|
+
// read. The engine captures those callbacks ONCE (see `EngineDeps.onTime`), so
|
|
200
|
+
// they must be stable and must not close over a render's values.
|
|
201
|
+
const fileUrlRef = useRef(fileUrl)
|
|
202
|
+
useEffect(() => { fileUrlRef.current = fileUrl }, [fileUrl])
|
|
203
|
+
const onTimeRef = useRef(onTimeUpdate)
|
|
204
|
+
useEffect(() => { onTimeRef.current = onTimeUpdate }, [onTimeUpdate])
|
|
205
|
+
const currentTimeRef = useRef(currentTime)
|
|
206
|
+
|
|
207
|
+
// ── The clock bridge ──────────────────────────────────────────────────────
|
|
208
|
+
//
|
|
209
|
+
// Two directions over ONE `PlaybackClock`, and the whole problem is telling
|
|
210
|
+
// them apart:
|
|
211
|
+
//
|
|
212
|
+
// engine → editor every tick, `emitTime` writes the playhead into
|
|
213
|
+
// `clock.set`. Timeline components re-render from it.
|
|
214
|
+
// editor → engine Scrubber/Timeline/track rows call `clock.set(t)`
|
|
215
|
+
// DIRECTLY (unchanged — they know nothing about either
|
|
216
|
+
// playback path). That arrives here as a changed
|
|
217
|
+
// `currentTime` prop and must become `engine.seek(t)`.
|
|
218
|
+
//
|
|
219
|
+
// `lastEmittedRef` is written BEFORE the value reaches `clock.set`, so by the
|
|
220
|
+
// time React re-renders with it the mirror is already in place and the scrub
|
|
221
|
+
// effect can recognize its own echo.
|
|
222
|
+
const lastEmittedRef = useRef(currentTime)
|
|
223
|
+
/**
|
|
224
|
+
* Mirrors the RAW (frames-consumed) time `emitTime` receives, alongside
|
|
225
|
+
* `lastEmittedRef`'s AUDIBLE (latency-compensated) mirror. `syncAudioTracks`
|
|
226
|
+
* callsites outside `emitTime` itself — the audio-lane-added and
|
|
227
|
+
* transport-transition effects — need this one: `<audio>` elements are
|
|
228
|
+
* frames-consumed devices on the same graph, and handing them the
|
|
229
|
+
* compensated value double-lags their output by `latencySeconds`.
|
|
230
|
+
*/
|
|
231
|
+
const lastRawTimeRef = useRef(currentTime)
|
|
232
|
+
/**
|
|
233
|
+
* Ring of values emitted since the last external seek.
|
|
234
|
+
*
|
|
235
|
+
* The dead-zone alone satisfies "an echo of the LATEST emission never seeks".
|
|
236
|
+
* It does not satisfy the stronger contract — *engine-originated echoes never
|
|
237
|
+
* re-trigger seeks* — because React can commit a render carrying an older
|
|
238
|
+
* snapshot: at 60Hz, one 100ms hitch puts `currentTime` ~6 frames behind
|
|
239
|
+
* `lastEmittedRef`, past the 0.05s dead zone, and the bridge would answer a
|
|
240
|
+
* frame it emitted itself with a backwards seek (which restarts the decode
|
|
241
|
+
* stream, which lengthens the hitch). Every stale echo is bit-exactly a value
|
|
242
|
+
* this hook emitted, so exact membership is the exact test.
|
|
243
|
+
*
|
|
244
|
+
* Cleared on every external seek — never on a transport change, because
|
|
245
|
+
* `pause()` publishes a status without ticking and a stale echo arriving just
|
|
246
|
+
* after it would then read as a scrub and rewind the playhead. Values in the
|
|
247
|
+
* ring are therefore always playback positions from the last ≤2s of the
|
|
248
|
+
* CURRENT seek segment, which a scrub target cannot plausibly hit bit-exactly
|
|
249
|
+
* (and if it did, it would be a scrub to where the playhead already is).
|
|
250
|
+
*/
|
|
251
|
+
const emittedRef = useRef<number[]>([])
|
|
252
|
+
|
|
253
|
+
const rememberEmitted = (t: number) => {
|
|
254
|
+
const ring = emittedRef.current
|
|
255
|
+
ring.push(t)
|
|
256
|
+
if (ring.length > EMIT_HISTORY) ring.splice(0, ring.length - EMIT_HISTORY)
|
|
257
|
+
}
|
|
258
|
+
const isEcho = (t: number) => emittedRef.current.includes(t)
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The transport as of RIGHT NOW, not as of the last published status.
|
|
262
|
+
*
|
|
263
|
+
* The engine emits the playhead and then publishes the status, so inside a
|
|
264
|
+
* tick `transportRef` is one step behind — and the two ticks where that
|
|
265
|
+
* matters are exactly the ones the audio lanes care about: the tick that
|
|
266
|
+
* starts playback (lanes must start with it) and the tick a looping clip
|
|
267
|
+
* stops on (lanes must stop with it). `transportRef` remains the fallback for
|
|
268
|
+
* the window between construction and the first status.
|
|
269
|
+
*/
|
|
270
|
+
const isEnginePlaying = () =>
|
|
271
|
+
(engineRef.current?.status().transport ?? transportRef.current) === 'playing'
|
|
272
|
+
|
|
273
|
+
// ── Audio lanes ───────────────────────────────────────────────────────────
|
|
274
|
+
// Element/GainNode lifecycle lifted from `useVideoPlayback` unchanged (same
|
|
275
|
+
// identity key, same "volume never churns elements" rule, same
|
|
276
|
+
// never-close-the-context cleanup). Only `syncAudioTracks` is rewritten.
|
|
277
|
+
const audioRefsMap = useRef<Map<string, HTMLAudioElement>>(new Map())
|
|
278
|
+
const audioSrcMap = useRef<Map<string, string>>(new Map())
|
|
279
|
+
const gainNodesMap = useRef<Map<string, GainNode>>(new Map())
|
|
280
|
+
// The live transport rate R (the shuttle's fast-forward). Read every time a
|
|
281
|
+
// lane is (re)synced so a lane that starts or resumes mid-shuttle inherits it.
|
|
282
|
+
const transportRateRef = useRef(1)
|
|
283
|
+
|
|
284
|
+
const unmutedAudioTracks = useMemo(
|
|
285
|
+
() => (project.audio?.tracks ?? []).filter(t => !t.muted && t.src),
|
|
286
|
+
[project.audio?.tracks],
|
|
287
|
+
)
|
|
288
|
+
const audioTrackIdentity = useMemo(
|
|
289
|
+
() => unmutedAudioTracks.map(t => `${t.id}:${t.src}`).join('|'),
|
|
290
|
+
[unmutedAudioTracks],
|
|
291
|
+
)
|
|
292
|
+
const unmutedAudioTracksRef = useRef(unmutedAudioTracks)
|
|
293
|
+
useEffect(() => { unmutedAudioTracksRef.current = unmutedAudioTracks }, [unmutedAudioTracks])
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Slave every audio lane to the engine's playhead.
|
|
297
|
+
*
|
|
298
|
+
* The legacy version computed the window and the fade envelope inline; this
|
|
299
|
+
* one asks `timeline-core`'s `audioWindow` — the pure port of exactly that
|
|
300
|
+
* arithmetic, including the derived-outPoint rule (the stored `outPoint` can
|
|
301
|
+
* drift out of sync with start/end during a trim and cause premature
|
|
302
|
+
* silence). Finishing that adoption is plan decision 5.
|
|
303
|
+
*
|
|
304
|
+
* Stable identity (`[]` + refs), because the engine's tick callback captures
|
|
305
|
+
* it once at construction.
|
|
306
|
+
*/
|
|
307
|
+
const syncAudioTracks = useCallback(function syncAudioTracks(playhead: number, playing: boolean) {
|
|
308
|
+
for (const track of unmutedAudioTracksRef.current) {
|
|
309
|
+
const el = audioRefsMap.current.get(track.id)
|
|
310
|
+
if (!el) continue
|
|
311
|
+
|
|
312
|
+
const win = audioWindow(track, playhead)
|
|
313
|
+
if (!win.active) {
|
|
314
|
+
if (!el.paused) el.pause()
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Speed the lane up with the picture (pitch-corrected). The playhead and
|
|
319
|
+
// the lane both advance R× real time, so `win.trackTime` and the element's
|
|
320
|
+
// own `currentTime` stay in step and the drift re-seek below keeps working
|
|
321
|
+
// in project-time terms — no need to scale its tolerance.
|
|
322
|
+
applyLaneRate(el, transportRateRef.current)
|
|
323
|
+
|
|
324
|
+
if (Math.abs(el.currentTime - win.trackTime) > AUDIO_SYNC_THRESHOLD_S) {
|
|
325
|
+
el.currentTime = Math.max(0, win.trackTime)
|
|
326
|
+
}
|
|
327
|
+
if (playing && el.paused) el.play().catch(() => {})
|
|
328
|
+
if (!playing && !el.paused) el.pause()
|
|
329
|
+
|
|
330
|
+
// `audioWindow.gain` is already `baseVolume * max(0, fadeMul)`.
|
|
331
|
+
const gain = gainNodesMap.current.get(track.id)
|
|
332
|
+
if (gain) gain.gain.value = win.gain
|
|
333
|
+
}
|
|
334
|
+
}, [])
|
|
335
|
+
|
|
336
|
+
// Create / destroy elements when the track SET changes (not on volume drags).
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
const map = audioRefsMap.current
|
|
339
|
+
const srcMap = audioSrcMap.current
|
|
340
|
+
const gains = gainNodesMap.current
|
|
341
|
+
const activeIds = new Set(unmutedAudioTracks.map(t => t.id))
|
|
342
|
+
|
|
343
|
+
for (const [id, el] of map) {
|
|
344
|
+
if (!activeIds.has(id)) {
|
|
345
|
+
el.pause()
|
|
346
|
+
el.src = ''
|
|
347
|
+
map.delete(id)
|
|
348
|
+
srcMap.delete(id)
|
|
349
|
+
gains.delete(id)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const track of unmutedAudioTracks) {
|
|
354
|
+
let el = map.get(track.id)
|
|
355
|
+
if (!el) {
|
|
356
|
+
el = new Audio()
|
|
357
|
+
el.preload = 'auto'
|
|
358
|
+
map.set(track.id, el)
|
|
359
|
+
// element → GainNode → destination, on the SHARED context (volume > 1.0).
|
|
360
|
+
// createMediaElementSource can only be called once per element.
|
|
361
|
+
const ctx = getSharedAudioContext()
|
|
362
|
+
const source = ctx.createMediaElementSource(el)
|
|
363
|
+
const gain = ctx.createGain()
|
|
364
|
+
gain.gain.value = track.volume ?? 1
|
|
365
|
+
source.connect(gain)
|
|
366
|
+
gain.connect(ctx.destination)
|
|
367
|
+
gains.set(track.id, gain)
|
|
368
|
+
}
|
|
369
|
+
if (srcMap.get(track.id) !== track.src) {
|
|
370
|
+
el.src = fileUrlRef.current(track.src!)
|
|
371
|
+
srcMap.set(track.id, track.src!)
|
|
372
|
+
}
|
|
373
|
+
const gain = gains.get(track.id)
|
|
374
|
+
if (gain) gain.gain.value = track.volume ?? 1
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// A lane added mid-session has to be placed at the current playhead
|
|
378
|
+
// immediately; the next engine tick would otherwise be the first thing to
|
|
379
|
+
// touch it, and while paused there is no next tick.
|
|
380
|
+
syncAudioTracks(lastRawTimeRef.current, isEnginePlaying())
|
|
381
|
+
// Keyed on the identity string ALONE, exactly as the legacy hook keys it: the
|
|
382
|
+
// effect must fire on adds/removes/src changes and never on a volume drag
|
|
383
|
+
// (which would tear down and rebuild every element mid-playback). The closure
|
|
384
|
+
// over `unmutedAudioTracks` is from the render in which the identity last
|
|
385
|
+
// changed, which is the render whose track set this effect is reconciling.
|
|
386
|
+
}, [audioTrackIdentity])
|
|
387
|
+
|
|
388
|
+
// Volume in place, no element churn.
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
for (const track of unmutedAudioTracks) {
|
|
391
|
+
const gain = gainNodesMap.current.get(track.id)
|
|
392
|
+
if (gain) gain.gain.value = track.volume ?? 1
|
|
393
|
+
}
|
|
394
|
+
}, [unmutedAudioTracks])
|
|
395
|
+
|
|
396
|
+
// Unmount only. The shared AudioContext is window-scoped and never closed.
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
const map = audioRefsMap.current
|
|
399
|
+
const srcMap = audioSrcMap.current
|
|
400
|
+
const gains = gainNodesMap.current
|
|
401
|
+
return () => {
|
|
402
|
+
for (const el of map.values()) { el.pause(); el.src = '' }
|
|
403
|
+
map.clear()
|
|
404
|
+
srcMap.clear()
|
|
405
|
+
gains.clear()
|
|
406
|
+
}
|
|
407
|
+
}, [])
|
|
408
|
+
|
|
409
|
+
// ── Engine lifecycle ──────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/** Mirror-then-forward. The ORDER is the bridge's whole contract. */
|
|
412
|
+
const emitTime = useCallback((t: number) => {
|
|
413
|
+
// `t` is frames-CONSUMED time (`samplesConsumed / sampleRate + anchor`);
|
|
414
|
+
// those frames become audible `latencySeconds` later. Painting to `t`
|
|
415
|
+
// while playing puts the picture ahead of the ear by that gap ("laggy
|
|
416
|
+
// audio") — subtract live off the shared context so a device switch
|
|
417
|
+
// (which changes `outputLatency`) is picked up on the very next tick.
|
|
418
|
+
//
|
|
419
|
+
// Skipped when NOT playing: the paused path's onTime fires from
|
|
420
|
+
// `scheduler.apply` on seek-land with the exact seek target, and
|
|
421
|
+
// compensating that would offset the scrubbed-to display.
|
|
422
|
+
//
|
|
423
|
+
// The store, `rememberEmitted` and `lastEmittedRef` all mirror the
|
|
424
|
+
// AUDIBLE value so the echo coming back through `currentTime` compares
|
|
425
|
+
// cleanly. `syncAudioTracks` gets the RAW `t`, though: `<audio>` elements
|
|
426
|
+
// are frames-consumed devices on the same graph, and syncing them to the
|
|
427
|
+
// audible value would double-lag their output by `latencySeconds`.
|
|
428
|
+
const playing = isEnginePlaying()
|
|
429
|
+
// `peek` never creates: production reaches the playing branch only after
|
|
430
|
+
// `togglePlay`'s gesture, which has already minted the shared ctx via
|
|
431
|
+
// `getSharedAudioContext()`. No ctx means no gesture yet, and the
|
|
432
|
+
// frames-consumed clock has nothing audible behind it to lag.
|
|
433
|
+
const ctx = playing ? peekSharedAudioContext() : undefined
|
|
434
|
+
const painted = ctx ? Math.max(0, t - latencySeconds(ctx)) : t
|
|
435
|
+
lastEmittedRef.current = painted
|
|
436
|
+
lastRawTimeRef.current = t
|
|
437
|
+
rememberEmitted(painted)
|
|
438
|
+
onTimeRef.current(painted)
|
|
439
|
+
syncAudioTracks(t, playing)
|
|
440
|
+
}, [syncAudioTracks])
|
|
441
|
+
|
|
442
|
+
const handleStatus = useCallback((next: EngineStatus) => {
|
|
443
|
+
// Written synchronously, ahead of the React state, because `emitTime` runs
|
|
444
|
+
// inside the same tick and needs the transport that is true NOW.
|
|
445
|
+
transportRef.current = next.transport
|
|
446
|
+
setStatus(next)
|
|
447
|
+
}, [])
|
|
448
|
+
|
|
449
|
+
const handleError = useCallback((message: string) => {
|
|
450
|
+
console.warn(`[montaj] ${message}`)
|
|
451
|
+
}, [])
|
|
452
|
+
|
|
453
|
+
// One engine per project IDENTITY. Edits go through `updateProject` below;
|
|
454
|
+
// a different project id is a teardown. The engine is built from a ref rather
|
|
455
|
+
// than the closed-over `project` so that an edit landing in the same commit
|
|
456
|
+
// as the identity change still builds from the newest timeline.
|
|
457
|
+
const projectId = project.id
|
|
458
|
+
const projectRef = useRef(project)
|
|
459
|
+
projectRef.current = project
|
|
460
|
+
/** The project object the engine has already been told about. */
|
|
461
|
+
const appliedProjectRef = useRef<Project | null>(null)
|
|
462
|
+
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
const engine = createEngine(projectRef.current, {
|
|
465
|
+
fileUrl: (path: string) => fileUrlRef.current(path),
|
|
466
|
+
onTime: emitTime,
|
|
467
|
+
onStatusChange: handleStatus,
|
|
468
|
+
onError: handleError,
|
|
469
|
+
startProjectS: currentTimeRef.current,
|
|
470
|
+
})
|
|
471
|
+
engineRef.current = engine
|
|
472
|
+
lastEmittedRef.current = currentTimeRef.current
|
|
473
|
+
lastRawTimeRef.current = currentTimeRef.current
|
|
474
|
+
emittedRef.current = []
|
|
475
|
+
// The project object the engine was just built with is, by definition,
|
|
476
|
+
// already applied — without this the edit effect below would re-apply it.
|
|
477
|
+
appliedProjectRef.current = projectRef.current
|
|
478
|
+
// The canvas ref callback fires during commit, BEFORE this effect, so on a
|
|
479
|
+
// first mount the canvas is already here and waiting to be bound.
|
|
480
|
+
if (canvasRef.current) engine.attach(canvasRef.current)
|
|
481
|
+
return () => {
|
|
482
|
+
engineRef.current = null
|
|
483
|
+
transportRef.current = 'idle'
|
|
484
|
+
engine.dispose()
|
|
485
|
+
}
|
|
486
|
+
}, [projectId, emitTime, handleStatus, handleError])
|
|
487
|
+
|
|
488
|
+
// Project EDITS. `updateProject` re-runs the tick against the new timeline —
|
|
489
|
+
// and is the path a `preparing` clip resolves through when SSE delivers its
|
|
490
|
+
// proxySrc. Skipped for the object the engine was just built with.
|
|
491
|
+
useEffect(() => {
|
|
492
|
+
const engine = engineRef.current
|
|
493
|
+
if (!engine) return
|
|
494
|
+
if (appliedProjectRef.current === project) return
|
|
495
|
+
appliedProjectRef.current = project
|
|
496
|
+
engine.updateProject(project)
|
|
497
|
+
}, [project])
|
|
498
|
+
|
|
499
|
+
// ── The scrub half of the bridge ──────────────────────────────────────────
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
currentTimeRef.current = currentTime
|
|
502
|
+
const engine = engineRef.current
|
|
503
|
+
if (!engine) {
|
|
504
|
+
// No engine yet (first render, or between project identities): the store
|
|
505
|
+
// is the only truth, so adopt it as the mirror rather than seeking a
|
|
506
|
+
// later engine to a stale value.
|
|
507
|
+
lastEmittedRef.current = currentTime
|
|
508
|
+
lastRawTimeRef.current = currentTime
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
if (isEcho(currentTime)) return
|
|
512
|
+
if (Math.abs(currentTime - lastEmittedRef.current) < SCRUB_DEAD_ZONE_S) return
|
|
513
|
+
// External scrub. Mirror FIRST so a re-render before the engine's own echo
|
|
514
|
+
// lands cannot fire a second seek for the same gesture.
|
|
515
|
+
lastEmittedRef.current = currentTime
|
|
516
|
+
lastRawTimeRef.current = currentTime
|
|
517
|
+
emittedRef.current = []
|
|
518
|
+
engine.seek(currentTime)
|
|
519
|
+
}, [currentTime])
|
|
520
|
+
|
|
521
|
+
// Transport transitions the tick cannot report: `pause()` publishes a status
|
|
522
|
+
// but never runs a tick, so the lanes would keep playing without this.
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
syncAudioTracks(lastRawTimeRef.current, status.transport === 'playing')
|
|
525
|
+
}, [status.transport, syncAudioTracks])
|
|
526
|
+
|
|
527
|
+
// ── Gestures ──────────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
const togglePlay = useCallback(() => {
|
|
530
|
+
// GESTURE-ANCHORED, and more load-bearing here than on the legacy path: the
|
|
531
|
+
// engine's master clock counts frames an AudioWorklet has rendered, and a
|
|
532
|
+
// suspended context renders none — so a suspended context freezes the
|
|
533
|
+
// PICTURE, not just the sound. Must be called synchronously inside the
|
|
534
|
+
// gesture for the browser to credit it.
|
|
535
|
+
//
|
|
536
|
+
// `getSharedAudioContext()` FIRST: `resumeAudioContextFromGesture`
|
|
537
|
+
// deliberately never creates the context (see its own doc), so on the
|
|
538
|
+
// very first play — nothing has reached line 307's lane-creation path yet
|
|
539
|
+
// for a project with no audio tracks, and a video-only clip's context is
|
|
540
|
+
// built inside `createMasterClock`, off the gesture stack entirely — the
|
|
541
|
+
// context would not exist yet and there would be nothing to resume. This
|
|
542
|
+
// creates it synchronously inside the gesture, so the browser credits the
|
|
543
|
+
// creation (and the resume that follows) as gesture-driven.
|
|
544
|
+
getSharedAudioContext()
|
|
545
|
+
resumeAudioContextFromGesture()
|
|
546
|
+
const engine = engineRef.current
|
|
547
|
+
if (!engine) return
|
|
548
|
+
if (engine.status().transport !== 'playing') {
|
|
549
|
+
// `play()` runs a tick, which emits the playhead for free.
|
|
550
|
+
engine.play()
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
engine.pause()
|
|
554
|
+
// `pause()` publishes a status but never ticks, so nothing else would push
|
|
555
|
+
// the exact stop position into the editor's clock — the store would keep
|
|
556
|
+
// whatever the last rendered frame's value was.
|
|
557
|
+
emitTime(engine.clock.now())
|
|
558
|
+
}, [emitTime])
|
|
559
|
+
|
|
560
|
+
// The J/K/L shuttle's rate knob. Push R onto the engine transport (project
|
|
561
|
+
// time advances R×, audio pitch-corrected by the engine's streaming
|
|
562
|
+
// time-stretch) and onto every audio lane element (`<audio>` slaved to the
|
|
563
|
+
// playhead) so music/VO fast-forward in lockstep rather than drifting. Stored
|
|
564
|
+
// in a ref so a lane started or re-synced later inherits the current rate
|
|
565
|
+
// (see `syncAudioTracks` and the lane-creation effect).
|
|
566
|
+
const setRate = useCallback((rate: number) => {
|
|
567
|
+
transportRateRef.current = rate
|
|
568
|
+
engineRef.current?.setRate(rate)
|
|
569
|
+
for (const el of audioRefsMap.current.values()) applyLaneRate(el, rate)
|
|
570
|
+
}, [])
|
|
571
|
+
|
|
572
|
+
// Space = play/pause. The legacy hook's keydown block is the spec: same
|
|
573
|
+
// typing-surface guard (an `<input>`, a `<textarea>` or any contentEditable
|
|
574
|
+
// host — which is what the caption/overlay editors and every modal field
|
|
575
|
+
// are), same `e.code === 'Space'` test, same `preventDefault()` (otherwise
|
|
576
|
+
// Space scrolls the editor), same gesture-anchored resume. What it does NOT
|
|
577
|
+
// reproduce is the canvas-project and in-gap branching: those existed because
|
|
578
|
+
// three different clocks owned playback in three different situations, and
|
|
579
|
+
// the engine has one transport for all of them.
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
582
|
+
const el = e.target as HTMLElement
|
|
583
|
+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable) return
|
|
584
|
+
if (e.code !== 'Space') return
|
|
585
|
+
e.preventDefault()
|
|
586
|
+
togglePlay()
|
|
587
|
+
}
|
|
588
|
+
document.addEventListener('keydown', onKeyDown)
|
|
589
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
590
|
+
}, [togglePlay])
|
|
591
|
+
|
|
592
|
+
// ── Canvas binding ────────────────────────────────────────────────────────
|
|
593
|
+
const attachCanvas = useCallback((canvas: HTMLCanvasElement | null) => {
|
|
594
|
+
canvasRef.current = canvas
|
|
595
|
+
engineRef.current?.attach(canvas)
|
|
596
|
+
}, [])
|
|
597
|
+
|
|
598
|
+
const setIsPlaying = useCallback(() => {
|
|
599
|
+
/* inert — see the interface note */
|
|
600
|
+
}, [])
|
|
601
|
+
|
|
602
|
+
const getStats = useCallback((): EngineStats | null => engineRef.current?.stats() ?? null, [])
|
|
603
|
+
|
|
604
|
+
const acquireDemux = useCallback((src: string): Promise<AcquiredDemux> => {
|
|
605
|
+
const engine = engineRef.current
|
|
606
|
+
if (!engine) return Promise.reject(new Error('scrub-source: engine not attached'))
|
|
607
|
+
return engine.acquireDemux(src)
|
|
608
|
+
}, [])
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
isPlaying: status.transport === 'playing',
|
|
612
|
+
setIsPlaying,
|
|
613
|
+
showVideo: status.picture === 'video',
|
|
614
|
+
togglePlay,
|
|
615
|
+
setRate,
|
|
616
|
+
isCanvasProject,
|
|
617
|
+
clips,
|
|
618
|
+
tracks0NonVideo,
|
|
619
|
+
overlayTracks,
|
|
620
|
+
status,
|
|
621
|
+
attachCanvas,
|
|
622
|
+
getStats,
|
|
623
|
+
acquireDemux,
|
|
624
|
+
}
|
|
625
|
+
}
|