@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -1,90 +1,120 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
2
|
+
import {
|
|
3
|
+
sourceWindow,
|
|
4
|
+
playbackSrcFor as resolvePlaybackSrc,
|
|
5
|
+
projectEnd as timelineProjectEnd,
|
|
6
|
+
audioWindow,
|
|
7
|
+
} from '@bycrux/timeline-core'
|
|
8
|
+
import { gateProxy, isProxyUsable, markProxyFailed } from './proxySupport'
|
|
9
|
+
import {
|
|
10
|
+
getSharedAudioContext,
|
|
11
|
+
latencySeconds,
|
|
12
|
+
peekSharedAudioContext,
|
|
13
|
+
resumeAudioContextFromGesture,
|
|
14
|
+
type MontajWindow,
|
|
15
|
+
} from './audio-context'
|
|
16
|
+
import type { EditorProject as Project, VisualItem, VisualTrack } from '../../schema'
|
|
17
|
+
import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../timeline/timeline-model'
|
|
8
18
|
|
|
9
19
|
// Typed extension for video elements that cache their GainNode
|
|
10
20
|
interface MontajVideoElement extends HTMLVideoElement {
|
|
11
21
|
__montajGain?: GainNode
|
|
12
22
|
}
|
|
13
23
|
|
|
24
|
+
// ── Source-window helpers ───────────────────────────────────────────────────
|
|
25
|
+
//
|
|
26
|
+
// The three functions below are thin wrappers over @bycrux/timeline-core with
|
|
27
|
+
// the variant pinned to 'preview'. The canonical implementation — and the full
|
|
28
|
+
// reasoning, with both the editor and render originals quoted verbatim next to
|
|
29
|
+
// the branch that reproduces them — lives in
|
|
30
|
+
// `montaj_assets/timeline-core/src/source-window.js`. Read that module's header
|
|
31
|
+
// before changing anything here; the same math also drives the render engine,
|
|
32
|
+
// and preview/render differences are deliberate, not accidental.
|
|
33
|
+
//
|
|
34
|
+
// The short version of why these exist at all:
|
|
35
|
+
//
|
|
36
|
+
// • `normalizedSrc` is a per-WINDOW normalized cache: it covers exactly
|
|
37
|
+
// [normalizedInPoint, normalizedInPoint + duration] of the original source
|
|
38
|
+
// and plays from its own time 0. Items always store inPoint/outPoint in
|
|
39
|
+
// ORIGINAL-source coordinates, so whenever the cache is the loaded src the
|
|
40
|
+
// points must be rebased by the cache origin
|
|
41
|
+
// (`normalizedInPoint ?? inPoint ?? 0`) or every seek lands in the wrong
|
|
42
|
+
// place. When `normalizedInPoint` is absent (legacy caches) the origin
|
|
43
|
+
// collapses to `inPoint`, reproducing the old rebase-to-0 behavior.
|
|
44
|
+
//
|
|
45
|
+
// • `nobg_preview_src` (VP9 WebM with alpha) covers the FULL source, not a
|
|
46
|
+
// window, so it is NOT rebased — and it takes precedence over the cache.
|
|
47
|
+
// `nobg_src` is the ProRes 4444 render-only artifact and is never loaded
|
|
48
|
+
// into a <video> element; browsers can't decode ProRes.
|
|
49
|
+
//
|
|
50
|
+
// • `proxySrc` (SP3) is the full-source, all-intra 720p H.264+Opus editing
|
|
51
|
+
// proxy generated automatically at import. It also covers the FULL
|
|
52
|
+
// source, so it is NOT rebased either — same shape as `nobg_preview_src`.
|
|
53
|
+
// It sits AFTER `nobg_preview_src` in the precedence chain (an alpha
|
|
54
|
+
// preview always wins over the plain proxy), and is preview-only: render
|
|
55
|
+
// never reads it, and it is never written into project.json by render.
|
|
56
|
+
|
|
57
|
+
// SP3 precedence, most to least specific: nobg_preview_src > proxySrc >
|
|
58
|
+
// normalizedSrc (rebased) > src.
|
|
59
|
+
|
|
14
60
|
/**
|
|
15
61
|
* Resolve the file path the browser should load for previewing this clip.
|
|
16
62
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* un-cut-out source. Mirrors the same pattern used by
|
|
21
|
-
* `OverlayItemsLayer.tsx:406` for tracks[1+] items; this generalises it to
|
|
22
|
-
* the main-track preview so bg-removed clips placed on tracks[0] don't show
|
|
23
|
-
* the wrong layer in preview while rendering correctly. Falls back to `src`
|
|
24
|
-
* when `nobg_preview_src` is absent (most clips).
|
|
25
|
-
*
|
|
26
|
-
* Note: `nobg_src` is the ProRes 4444 render-only artifact and is NEVER
|
|
27
|
-
* loaded into a `<video>` element — browsers can't decode ProRes.
|
|
63
|
+
* `'preview'` always yields a string (`''` when the clip has no src field at
|
|
64
|
+
* all) — the `?? ''` below is a type narrowing, not a runtime fallback; see the
|
|
65
|
+
* `src` note on `SourceWindow` in timeline-core's index.d.ts.
|
|
28
66
|
*/
|
|
29
|
-
function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string; normalizedSrc?: string }): string {
|
|
30
|
-
|
|
67
|
+
function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string; normalizedSrc?: string; proxySrc?: string }): string {
|
|
68
|
+
// gateProxy (SP3 fix B2): strip an unsupported/failed proxy BEFORE the tier
|
|
69
|
+
// chain runs, so unsupported browsers fall through to normalizedSrc/src
|
|
70
|
+
// instead of a silently-black <video>.
|
|
71
|
+
return resolvePlaybackSrc(gateProxy(clip), 'preview') ?? ''
|
|
31
72
|
}
|
|
32
73
|
|
|
33
74
|
/**
|
|
34
75
|
* The inPoint the preview should SEEK to for this clip, accounting for the
|
|
35
|
-
* normalizedSrc cache origin.
|
|
36
|
-
*
|
|
37
|
-
* A `normalizedSrc` cache is a trimmed file that covers exactly
|
|
38
|
-
* [normalizedInPoint, normalizedInPoint + duration] of the original source and
|
|
39
|
-
* plays starting at time 0. When `playbackSrcFor` chooses the cache as the
|
|
40
|
-
* playback src, we must rebase by the cache origin so the seek position is
|
|
41
|
-
* relative to the cache's own timeline.
|
|
42
|
-
*
|
|
43
|
-
* The origin is `clip.normalizedInPoint ?? clip.inPoint ?? 0`:
|
|
44
|
-
* - When `normalizedInPoint` is set, the cache was built for a specific window
|
|
45
|
-
* that may differ from the current inPoint (e.g. after a start-trim): the
|
|
46
|
-
* cache still covers the new window, but we must subtract the origin so the
|
|
47
|
-
* seek lands at the right position inside the cache.
|
|
48
|
-
* - When `normalizedInPoint` is absent (legacy), the cache was built assuming
|
|
49
|
-
* it starts at the clip's inPoint → origin = inPoint → effectiveInPoint = 0
|
|
50
|
-
* (reproduces the old rebase-to-0 behavior).
|
|
51
|
-
*
|
|
52
|
-
* This mirrors render's `collectAllItems` (montaj_assets/render/render.js),
|
|
53
|
-
* which rebases inPoint by the same origin.
|
|
54
|
-
*
|
|
55
|
-
* The rebase applies ONLY when the cache is actually the chosen src.
|
|
56
|
-
* `nobg_preview_src` takes precedence in `playbackSrcFor` and is NOT a window
|
|
57
|
-
* cache (it covers the full source), so it keeps the original inPoint — exactly
|
|
58
|
-
* as render's nobg path does.
|
|
76
|
+
* normalizedSrc cache origin. `sourceWindow(clip, 'preview').inPoint`.
|
|
59
77
|
*/
|
|
60
|
-
export function effectiveInPoint(clip: { inPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number {
|
|
61
|
-
|
|
62
|
-
if (!usingNormalizedCache) return clip.inPoint ?? 0
|
|
63
|
-
const origin = clip.normalizedInPoint ?? clip.inPoint ?? 0
|
|
64
|
-
return (clip.inPoint ?? 0) - origin
|
|
78
|
+
export function effectiveInPoint(clip: { inPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; proxySrc?: string; src?: string }): number {
|
|
79
|
+
return sourceWindow(gateProxy(clip), 'preview').inPoint
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
/**
|
|
68
|
-
* The outPoint in the loaded src's own timeline
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* `
|
|
72
|
-
* cache origin.
|
|
73
|
-
*
|
|
74
|
-
* The origin is `clip.normalizedInPoint ?? clip.inPoint ?? 0` (same as
|
|
75
|
-
* effectiveInPoint). Legacy clips without `normalizedInPoint` default the
|
|
76
|
-
* origin to inPoint, reproducing the old (outPoint - inPoint) window-length
|
|
77
|
-
* behavior.
|
|
83
|
+
* The outPoint in the loaded src's own timeline, rebased by the cache origin
|
|
84
|
+
* when the normalizedSrc cache is what's actually loaded (the boundary/loop
|
|
85
|
+
* checks below compare against `video.currentTime`, i.e. cache time).
|
|
86
|
+
* `sourceWindow(clip, 'preview').outPoint`.
|
|
78
87
|
*
|
|
79
88
|
* Returns undefined when no outPoint is stored, so callers keep their existing
|
|
80
89
|
* fallback (clip.end - clip.start + effectiveInPoint).
|
|
81
90
|
*/
|
|
82
|
-
export function effectiveOutPoint(clip: { inPoint?: number; outPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number | undefined {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
export function effectiveOutPoint(clip: { inPoint?: number; outPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; proxySrc?: string; src?: string }): number | undefined {
|
|
92
|
+
return sourceWindow(gateProxy(clip), 'preview').outPoint
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The GainNode value for one clip on `track` — the ONLY place this hook turns
|
|
97
|
+
* audio settings into a number.
|
|
98
|
+
*
|
|
99
|
+
* Track settings fold into the clip's own via `effectiveItemAudio`: volume
|
|
100
|
+
* MULTIPLIES (a clip the editor already turned down stays proportionally
|
|
101
|
+
* quieter when its track is pulled down) and mute is either/or. `track` is
|
|
102
|
+
* `undefined` for a project with no tracks at all, and its `volume`/`muted` are
|
|
103
|
+
* absent on every track nobody has touched — both reduce to the clip's own
|
|
104
|
+
* values, which is what every project got before track settings existed.
|
|
105
|
+
*
|
|
106
|
+
* The four gain-setting sites below all route through this rather than reading
|
|
107
|
+
* `clip.muted`/`clip.volume` themselves. Folding by writing the effective
|
|
108
|
+
* values ONTO the clip is not an option: `clips` holds the project's own item
|
|
109
|
+
* objects by reference (see `enabledTrackItems`), and the renderer and the
|
|
110
|
+
* server both mutate those in place.
|
|
111
|
+
*/
|
|
112
|
+
export function clipGain(
|
|
113
|
+
track: Pick<VisualTrack, 'volume' | 'muted'> | undefined,
|
|
114
|
+
clip: Pick<VisualItem, 'volume' | 'muted'>,
|
|
115
|
+
): number {
|
|
116
|
+
const { volume, muted } = effectiveItemAudio(track, clip)
|
|
117
|
+
return muted ? 0 : volume
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
export function useVideoPlayback(
|
|
@@ -123,6 +153,10 @@ export function useVideoPlayback(
|
|
|
123
153
|
// Keep ref in sync so effects with narrow deps can read current playing state
|
|
124
154
|
useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])
|
|
125
155
|
const [showVideo, setShowVideo] = useState(true)
|
|
156
|
+
// Bumped when a proxy fails to decode (SP3 fix B2) — the clip-identity
|
|
157
|
+
// effect depends on it, so the bump forces the reload that drops the
|
|
158
|
+
// now-suppressed proxy tier.
|
|
159
|
+
const [proxyFailTick, setProxyFailTick] = useState(0)
|
|
126
160
|
|
|
127
161
|
// Gap clock — advances time through lift-style gaps between primary clips
|
|
128
162
|
const gapRAFRef = useRef<number | null>(null)
|
|
@@ -136,72 +170,66 @@ export function useVideoPlayback(
|
|
|
136
170
|
const fileUrlRef = useRef(fileUrl)
|
|
137
171
|
useEffect(() => { fileUrlRef.current = fileUrl }, [fileUrl])
|
|
138
172
|
|
|
173
|
+
// The AUDIBLE-time bridge to the store. Every playback-advance emission in
|
|
174
|
+
// this hook (rAF canvas tick, gap tick, `handleTimeUpdate`, clip-switch and
|
|
175
|
+
// end-of-clip snaps) routes through this instead of calling `onTimeUpdate`
|
|
176
|
+
// directly. The `<video>` element's `currentTime` and the RAF wall-clock both
|
|
177
|
+
// report the same frames-consumed lead the engine's master clock does — the
|
|
178
|
+
// audio graph is shared — so the picture leads the ear by exactly
|
|
179
|
+
// `latencySeconds` unless we subtract it here. Read live off the shared ctx
|
|
180
|
+
// so device switches take effect on the very next emission; clamp ≥ 0.
|
|
181
|
+
// Scrubs never come through here: the scrub effect updates `lastTimeRef`
|
|
182
|
+
// without emitting, so seek/scrub display is unaffected.
|
|
183
|
+
const emitTime = useCallback((t: number) => {
|
|
184
|
+
// `peek` never creates: production reaches this only during playback, whose
|
|
185
|
+
// gesture-anchored `togglePlay` has already minted the shared ctx — no ctx
|
|
186
|
+
// means no gesture yet, so the frames-consumed clock has nothing audible
|
|
187
|
+
// behind it to lag, and pass-through is the correct behavior.
|
|
188
|
+
const ctx = peekSharedAudioContext()
|
|
189
|
+
onTimeUpdate(ctx ? Math.max(0, t - latencySeconds(ctx)) : t)
|
|
190
|
+
}, [onTimeUpdate])
|
|
191
|
+
|
|
139
192
|
function getActiveVideo() { return activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current }
|
|
140
193
|
function getInactiveVideo() { return activeSlotRef.current === 0 ? video1Ref.current : video0Ref.current }
|
|
141
194
|
|
|
142
195
|
// ── Video timeline ─────────────────────────────────────────────────────────
|
|
143
196
|
// Only video items drive the double-buffer player; non-video items (images, etc.)
|
|
144
197
|
// in tracks[0] are exposed separately for the preview to render as a background layer.
|
|
145
|
-
const clips = useMemo(() => (project
|
|
146
|
-
const tracks0NonVideo = useMemo(() => (project
|
|
147
|
-
const overlayTracks = useMemo(() => project.
|
|
198
|
+
const clips = useMemo(() => (enabledTrackItems(project)[0] ?? []).filter(c => c.type === 'video').sort((a, b) => a.start - b.start), [project])
|
|
199
|
+
const tracks0NonVideo = useMemo(() => (enabledTrackItems(project)[0] ?? []).filter(c => c.type !== 'video'), [project])
|
|
200
|
+
const overlayTracks = useMemo(() => enabledTrackItems(project).slice(1), [project])
|
|
201
|
+
|
|
202
|
+
// The track `clips` came out of, kept alongside them because its own
|
|
203
|
+
// volume/mute fold into every clip on it (see `clipGain`).
|
|
204
|
+
// `enabledTrackItems(project)[0]` hands back items and drops the track object
|
|
205
|
+
// that carries those settings; `enabledTracks` is the same filter in the same
|
|
206
|
+
// order, so `[0]` here is `[0]` there by construction.
|
|
207
|
+
const videoTrack = useMemo(() => enabledTracks(project)[0], [project])
|
|
148
208
|
|
|
149
209
|
// Canvas project: no primary video in tracks[0] (e.g. image-only background track)
|
|
150
210
|
const isCanvasProject = clips.length === 0
|
|
151
211
|
|
|
152
212
|
// Total project end — includes opaque overlays and audio that extend beyond video clips.
|
|
153
213
|
// Used to decide whether to keep playing after the last video clip ends.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
214
|
+
//
|
|
215
|
+
// SP4 T8: this VIDEO-mode formula is `@bycrux/timeline-core`'s `projectEnd`
|
|
216
|
+
// (`src/durations.js`) ported verbatim FROM this exact memo — the two are
|
|
217
|
+
// byte-identical for every well-formed project, so calling the shared
|
|
218
|
+
// implementation here is a no-op substitution, not a behavior change. This
|
|
219
|
+
// is deliberately NOT shared with the CANVAS-mode ceiling below
|
|
220
|
+
// (`canvasMaxEndRef`, which excludes audio) — the two ends are legitimately
|
|
221
|
+
// different formulas (see `durations.js`'s module header) and only this one
|
|
222
|
+
// has a shared home.
|
|
223
|
+
const projectEnd = useMemo(() => timelineProjectEnd(withEnabledItemTracks(project)), [project])
|
|
160
224
|
|
|
161
225
|
// Wire a video slot through a Web Audio GainNode (once per element — createMediaElementSource
|
|
162
226
|
// can only be called once). After this, video.volume/muted have no audible effect; all volume
|
|
163
227
|
// control goes through the GainNode, which supports values > 1.0 for amplification.
|
|
164
228
|
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// suspended → silent. Worse, video frame production is gated on the wired
|
|
170
|
-
// AudioContext clock running, so a suspended context with a wired <video>
|
|
171
|
-
// produces "video plays but no frames render" after a hard refresh.
|
|
172
|
-
// Stash the single shared context on `window` so it survives strict-mode remounts
|
|
173
|
-
// and is reused across audio tracks, video slots, and re-mounts.
|
|
174
|
-
function getSharedAudioContext(): AudioContext {
|
|
175
|
-
const w = window as Window & MontajWindow
|
|
176
|
-
// If the cached context is closed (shouldn't happen, but defensive), recreate.
|
|
177
|
-
if (!w.__montajSharedCtx || w.__montajSharedCtx.state === 'closed') {
|
|
178
|
-
w.__montajSharedCtx = new AudioContext()
|
|
179
|
-
}
|
|
180
|
-
return w.__montajSharedCtx
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* MUST be called from inside a user-gesture handler (keydown, click, etc.).
|
|
185
|
-
* Browsers credit a `resume()` call as gesture-driven only when it happens
|
|
186
|
-
* synchronously inside a gesture-rooted call stack. Calling resume() from
|
|
187
|
-
* a useEffect or a setTimeout silently fails — the context stays suspended.
|
|
188
|
-
*
|
|
189
|
-
* Symptom of a suspended context with a wired <video>: video appears to
|
|
190
|
-
* play (paused=false, currentTime advances internally per the DOM clock)
|
|
191
|
-
* but no frames render and no audio plays — the entire pipeline is gated
|
|
192
|
-
* on the AudioContext clock running. This bites specifically after a page
|
|
193
|
-
* refresh, because SPA navigation carries gesture activation across pages
|
|
194
|
-
* but a hard reload does not.
|
|
195
|
-
*/
|
|
196
|
-
function resumeAudioContextFromGesture() {
|
|
197
|
-
const w = window as Window & MontajWindow
|
|
198
|
-
const ctx: AudioContext | undefined = w.__montajSharedCtx
|
|
199
|
-
if (ctx && ctx.state === 'suspended') {
|
|
200
|
-
// Fire-and-forget; resume() returns a Promise but the gesture-credit
|
|
201
|
-
// happens at the synchronous call site, not when the promise resolves.
|
|
202
|
-
ctx.resume().catch(() => {})
|
|
203
|
-
}
|
|
204
|
-
}
|
|
229
|
+
// `getSharedAudioContext` / `resumeAudioContextFromGesture` used to live here as
|
|
230
|
+
// private helpers; SP4 T4 MOVED them to `./audio-context` (unchanged) so the
|
|
231
|
+
// WebCodecs engine consumes the same context and the same gesture rule instead
|
|
232
|
+
// of growing a second copy. Their full rationale lives in that module's header.
|
|
205
233
|
|
|
206
234
|
/**
|
|
207
235
|
* Start playback on a wired <video> from a user gesture. Video frame
|
|
@@ -274,22 +302,25 @@ export function useVideoPlayback(
|
|
|
274
302
|
}
|
|
275
303
|
|
|
276
304
|
// Set video clip volume via GainNode (supports amplification > 1.0). Muted clips
|
|
277
|
-
// get gain 0;
|
|
305
|
+
// — or clips on a muted track — get gain 0; the rest get the clip's volume
|
|
306
|
+
// scaled by its track's (`clipGain`). No-op until the slot is
|
|
278
307
|
// wired (first play) — there's no audio to control on a paused poster, and
|
|
279
308
|
// wiring here would gate the poster frame on the suspended context.
|
|
280
309
|
function applyClipVolume(clip: { muted?: boolean; volume?: number }) {
|
|
281
310
|
const slot = activeSlotRef.current
|
|
282
311
|
const gain = getVideoGain(slot)
|
|
283
|
-
if (gain) gain.gain.value =
|
|
312
|
+
if (gain) gain.gain.value = clipGain(videoTrack, clip)
|
|
284
313
|
}
|
|
285
314
|
|
|
286
|
-
// Apply video clip volume via Web Audio GainNode (supports > 1.0 amplification)
|
|
315
|
+
// Apply video clip volume via Web Audio GainNode (supports > 1.0 amplification).
|
|
316
|
+
// `videoTrack` is a dep in its own right: pulling the TRACK's fader while the
|
|
317
|
+
// clips themselves are untouched has to reach the live gain node too.
|
|
287
318
|
useEffect(() => {
|
|
288
319
|
const idx = activeIdxRef.current
|
|
289
320
|
const clip = clips[idx]
|
|
290
321
|
if (!clip) return
|
|
291
322
|
applyClipVolume(clip)
|
|
292
|
-
}, [clips, activeSlot])
|
|
323
|
+
}, [clips, videoTrack, activeSlot])
|
|
293
324
|
|
|
294
325
|
// maxEnd for the canvas rAF clock — the furthest overlay/caption end. Kept in
|
|
295
326
|
// a ref, updated by its own cheap effect, so the rAF effect below doesn't tear
|
|
@@ -313,7 +344,7 @@ export function useVideoPlayback(
|
|
|
313
344
|
const dt = (ms - rafLastMs.current) / 1000
|
|
314
345
|
const next = Math.min(lastTimeRef.current + dt, maxEnd)
|
|
315
346
|
lastTimeRef.current = next
|
|
316
|
-
|
|
347
|
+
emitTime(next)
|
|
317
348
|
if (next >= maxEnd) {
|
|
318
349
|
setIsPlaying(false)
|
|
319
350
|
rafRef.current = null
|
|
@@ -336,7 +367,7 @@ export function useVideoPlayback(
|
|
|
336
367
|
return () => {
|
|
337
368
|
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
|
338
369
|
}
|
|
339
|
-
}, [isPlaying, isCanvasProject,
|
|
370
|
+
}, [isPlaying, isCanvasProject, emitTime])
|
|
340
371
|
|
|
341
372
|
// ── Multi-track audio management ───────────────────────────────────────────
|
|
342
373
|
// Derive unmuted tracks. The full tracks array is a new reference on every
|
|
@@ -432,54 +463,33 @@ export function useVideoPlayback(
|
|
|
432
463
|
const unmutedAudioTracksRef = useRef(unmutedAudioTracks)
|
|
433
464
|
useEffect(() => { unmutedAudioTracksRef.current = unmutedAudioTracks }, [unmutedAudioTracks])
|
|
434
465
|
|
|
466
|
+
// SP4 T8: the window/gain arithmetic is routed through timeline-core's
|
|
467
|
+
// `audioWindow` (`src/audio.js`) — the pure port of exactly this logic,
|
|
468
|
+
// derived-outPoint rule included — the same way `useEnginePlayback.ts`
|
|
469
|
+
// already does for the WebCodecs engine path. The 0.3s re-seek threshold
|
|
470
|
+
// and all other imperative behavior (play/pause calls, gain writes) are
|
|
471
|
+
// kept exactly; only the pure MATH moved to the shared implementation.
|
|
435
472
|
const syncAudioTracks = useCallback(function syncAudioTracks(playhead: number, playing: boolean) {
|
|
436
473
|
for (const track of unmutedAudioTracksRef.current) {
|
|
437
474
|
const el = audioRefsMap.current.get(track.id)
|
|
438
475
|
if (!el) continue
|
|
439
476
|
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
// Derive outPoint from the timeline span — the stored outPoint can drift
|
|
443
|
-
// out of sync with start/end during trim operations, causing premature silence.
|
|
444
|
-
// The HTML audio element naturally stops at end-of-file, so sourceDuration
|
|
445
|
-
// acts as an implicit ceiling without needing an explicit check here.
|
|
446
|
-
const outPoint = inPt + (track.end - track.start)
|
|
447
|
-
const outsideWindow =
|
|
448
|
-
playhead < track.start ||
|
|
449
|
-
playhead >= track.end ||
|
|
450
|
-
trackTime < 0 ||
|
|
451
|
-
trackTime >= outPoint
|
|
452
|
-
|
|
453
|
-
if (outsideWindow) {
|
|
477
|
+
const win = audioWindow(track, playhead)
|
|
478
|
+
if (!win.active) {
|
|
454
479
|
if (!el.paused) el.pause()
|
|
455
480
|
continue
|
|
456
481
|
}
|
|
457
482
|
|
|
458
|
-
if (Math.abs(el.currentTime - trackTime) > 0.3) {
|
|
459
|
-
el.currentTime = Math.max(0, trackTime)
|
|
483
|
+
if (Math.abs(el.currentTime - win.trackTime) > 0.3) {
|
|
484
|
+
el.currentTime = Math.max(0, win.trackTime)
|
|
460
485
|
}
|
|
461
486
|
|
|
462
487
|
if (playing && el.paused) el.play().catch(() => {})
|
|
463
488
|
if (!playing && !el.paused) el.pause()
|
|
464
489
|
|
|
465
|
-
//
|
|
490
|
+
// `audioWindow.gain` is already `baseVolume * max(0, fadeMul)`.
|
|
466
491
|
const gain = gainNodesMap.current.get(track.id)
|
|
467
|
-
if (gain)
|
|
468
|
-
const fadeIn = track.fadeIn ?? 0
|
|
469
|
-
const fadeOut = track.fadeOut ?? 0
|
|
470
|
-
const baseVol = track.volume ?? 1
|
|
471
|
-
const elapsed = playhead - track.start
|
|
472
|
-
const remaining = track.end - playhead
|
|
473
|
-
|
|
474
|
-
let fadeMul = 1
|
|
475
|
-
if (fadeIn > 0 && elapsed < fadeIn) {
|
|
476
|
-
fadeMul = elapsed / fadeIn
|
|
477
|
-
}
|
|
478
|
-
if (fadeOut > 0 && remaining < fadeOut) {
|
|
479
|
-
fadeMul = Math.min(fadeMul, remaining / fadeOut)
|
|
480
|
-
}
|
|
481
|
-
gain.gain.value = baseVol * Math.max(0, fadeMul)
|
|
482
|
-
}
|
|
492
|
+
if (gain) gain.gain.value = win.gain
|
|
483
493
|
}
|
|
484
494
|
}, [])
|
|
485
495
|
|
|
@@ -542,9 +552,17 @@ export function useVideoPlayback(
|
|
|
542
552
|
// Only reload if the actual clip sources/trim points changed — not just overlay edits.
|
|
543
553
|
// Identity includes nobg_preview_src so a bg-removal completing mid-session
|
|
544
554
|
// (the field appearing on a clip whose src was already loaded) triggers a
|
|
545
|
-
// reload to swap the preview to the cutout version.
|
|
555
|
+
// reload to swap the preview to the cutout version. Same reasoning covers
|
|
556
|
+
// proxySrc: an SP3 proxy arriving via SSE mid-session (the field appearing
|
|
557
|
+
// on a clip already loaded from its original src) must also trigger a
|
|
558
|
+
// reload, or the <video> element keeps playing the pre-proxy source until
|
|
559
|
+
// the next unrelated identity change happens to flush it.
|
|
560
|
+
// The proxy component is GATED (SP3 fix B2): an unsupported or
|
|
561
|
+
// failed-this-session proxy contributes '' — so marking a proxy failed
|
|
562
|
+
// (proxyFailTick bump) changes the identity and triggers the reload that
|
|
563
|
+
// swaps the clip back to its master.
|
|
546
564
|
const identity = clips
|
|
547
|
-
.map(c => `${c.nobg_preview_src ?? ''}|${c.src}|${c.inPoint ?? 0}|${c.outPoint ?? ''}`)
|
|
565
|
+
.map(c => `${c.nobg_preview_src ?? ''}|${isProxyUsable(c.proxySrc) ? c.proxySrc : ''}|${c.src}|${c.inPoint ?? 0}|${c.outPoint ?? ''}`)
|
|
548
566
|
.join(',')
|
|
549
567
|
if (identity === clipsSourceRef.current) return
|
|
550
568
|
clipsSourceRef.current = identity
|
|
@@ -559,7 +577,7 @@ export function useVideoPlayback(
|
|
|
559
577
|
// Clear inactive slot
|
|
560
578
|
const inactive = getInactiveVideo()
|
|
561
579
|
if (inactive) { inactive.pause(); inactive.removeAttribute('src') }
|
|
562
|
-
}, [clips])
|
|
580
|
+
}, [clips, proxyFailTick])
|
|
563
581
|
|
|
564
582
|
const handlePause = useCallback(() => {
|
|
565
583
|
// Ignore pause events while the gap clock owns playback state
|
|
@@ -580,7 +598,7 @@ export function useVideoPlayback(
|
|
|
580
598
|
const elapsed = (performance.now() - gapWallRef.current) / 1000
|
|
581
599
|
const t = Math.min(gapFromRef.current + elapsed, gapTargetRef.current)
|
|
582
600
|
lastTimeRef.current = t
|
|
583
|
-
|
|
601
|
+
emitTime(t)
|
|
584
602
|
|
|
585
603
|
if (t < gapTargetRef.current) {
|
|
586
604
|
gapRAFRef.current = requestAnimationFrame(tickGap)
|
|
@@ -601,13 +619,13 @@ export function useVideoPlayback(
|
|
|
601
619
|
const ns = (1 - activeSlotRef.current) as 0 | 1
|
|
602
620
|
const nv = ns === 0 ? video0Ref.current : video1Ref.current
|
|
603
621
|
lastTimeRef.current = nc.start
|
|
604
|
-
|
|
622
|
+
emitTime(nc.start)
|
|
605
623
|
activeIdxRef.current = ni
|
|
606
624
|
if (nv) {
|
|
607
625
|
const src = fileUrlRef.current(playbackSrcFor(nc))
|
|
608
626
|
if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = effectiveInPoint(nc) }
|
|
609
627
|
const gain = ensureVideoGain(ns)
|
|
610
|
-
if (gain) gain.gain.value =
|
|
628
|
+
if (gain) gain.gain.value = clipGain(videoTrack, nc)
|
|
611
629
|
playSoon(nv)
|
|
612
630
|
}
|
|
613
631
|
void (activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current)?.pause()
|
|
@@ -615,7 +633,7 @@ export function useVideoPlayback(
|
|
|
615
633
|
setActiveSlot(ns)
|
|
616
634
|
setShowVideo(true)
|
|
617
635
|
preloadSrcRef.current = ''
|
|
618
|
-
}, [clips,
|
|
636
|
+
}, [clips, videoTrack, emitTime])
|
|
619
637
|
|
|
620
638
|
// Scrub: seek active slot when currentTime jumps externally
|
|
621
639
|
useEffect(() => {
|
|
@@ -725,7 +743,7 @@ export function useVideoPlayback(
|
|
|
725
743
|
inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
|
|
726
744
|
const inactiveSlot = (1 - slot) as 0 | 1
|
|
727
745
|
const nextGain = ensureVideoGain(inactiveSlot)
|
|
728
|
-
if (nextGain) nextGain.gain.value =
|
|
746
|
+
if (nextGain) nextGain.gain.value = clipGain(videoTrack, clips[nextIdx])
|
|
729
747
|
}
|
|
730
748
|
}
|
|
731
749
|
|
|
@@ -770,7 +788,7 @@ export function useVideoPlayback(
|
|
|
770
788
|
const nextVideo = nextSlot === 0 ? video0Ref.current : video1Ref.current
|
|
771
789
|
|
|
772
790
|
lastTimeRef.current = next.start
|
|
773
|
-
|
|
791
|
+
emitTime(next.start)
|
|
774
792
|
activeIdxRef.current = nextIdx
|
|
775
793
|
|
|
776
794
|
if (nextVideo) {
|
|
@@ -780,7 +798,7 @@ export function useVideoPlayback(
|
|
|
780
798
|
nextVideo.currentTime = effectiveInPoint(next)
|
|
781
799
|
}
|
|
782
800
|
const nextGain = ensureVideoGain(nextSlot)
|
|
783
|
-
if (nextGain) nextGain.gain.value =
|
|
801
|
+
if (nextGain) nextGain.gain.value = clipGain(videoTrack, next)
|
|
784
802
|
playSoon(nextVideo)
|
|
785
803
|
}
|
|
786
804
|
|
|
@@ -794,7 +812,7 @@ export function useVideoPlayback(
|
|
|
794
812
|
video.pause()
|
|
795
813
|
const finalTime = clips[activeIdxRef.current].end
|
|
796
814
|
lastTimeRef.current = finalTime
|
|
797
|
-
|
|
815
|
+
emitTime(finalTime)
|
|
798
816
|
|
|
799
817
|
// If overlays or audio extend beyond the last video clip,
|
|
800
818
|
// continue advancing time via the gap clock (shows overlays, plays audio).
|
|
@@ -818,15 +836,41 @@ export function useVideoPlayback(
|
|
|
818
836
|
if (clip.loop && t >= clip.end) {
|
|
819
837
|
video.pause()
|
|
820
838
|
lastTimeRef.current = clip.end
|
|
821
|
-
|
|
839
|
+
emitTime(clip.end)
|
|
822
840
|
for (const el of audioRefsMap.current.values()) { if (!el.paused) el.pause() }
|
|
823
841
|
setIsPlaying(false)
|
|
824
842
|
return
|
|
825
843
|
}
|
|
826
844
|
|
|
827
845
|
lastTimeRef.current = t
|
|
828
|
-
|
|
829
|
-
}, [clips,
|
|
846
|
+
emitTime(t)
|
|
847
|
+
}, [clips, videoTrack, emitTime])
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* SP3 fix B2 — decode-failure fallback. Wired to both <video> slots'
|
|
851
|
+
* onError. If the failing element was playing a clip's proxy, suppress that
|
|
852
|
+
* proxy for the session and force a reload (via proxyFailTick → the
|
|
853
|
+
* clip-identity effect), which re-selects src without the proxy tier.
|
|
854
|
+
* Non-proxy errors keep the legacy behavior (logged, nothing else — there
|
|
855
|
+
* is no better source to fall back to).
|
|
856
|
+
*/
|
|
857
|
+
const handleVideoError = useCallback((slot: 0 | 1) => {
|
|
858
|
+
const el = (slot === 0 ? video0Ref : video1Ref).current
|
|
859
|
+
const current = el?.currentSrc || el?.src || ''
|
|
860
|
+
// fileUrl() builds `/api/files?path=<encodeURIComponent(abs)>`, so the
|
|
861
|
+
// encoded proxy path appearing in the element's src identifies the proxy
|
|
862
|
+
// as what failed, regardless of URL absolutization.
|
|
863
|
+
const clip = clips.find(c => c.proxySrc && isProxyUsable(c.proxySrc)
|
|
864
|
+
&& current.includes(encodeURIComponent(c.proxySrc)))
|
|
865
|
+
if (clip?.proxySrc) {
|
|
866
|
+
console.warn(
|
|
867
|
+
`[montaj] proxy failed to decode — falling back to the master for this session: ${clip.proxySrc}`)
|
|
868
|
+
markProxyFailed(clip.proxySrc)
|
|
869
|
+
setProxyFailTick(t => t + 1)
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
console.warn(`[montaj] video error on slot ${slot} (src: ${current || 'none'})`)
|
|
873
|
+
}, [clips])
|
|
830
874
|
|
|
831
875
|
const handleEnded = useCallback(() => {
|
|
832
876
|
// For looping clips the ended event fires when the source video reaches its natural end.
|
|
@@ -926,11 +970,11 @@ export function useVideoPlayback(
|
|
|
926
970
|
handleTimeUpdate,
|
|
927
971
|
handlePause,
|
|
928
972
|
handleEnded,
|
|
973
|
+
handleVideoError,
|
|
929
974
|
togglePlay,
|
|
930
975
|
isCanvasProject,
|
|
931
976
|
clips,
|
|
932
977
|
tracks0NonVideo,
|
|
933
978
|
overlayTracks,
|
|
934
|
-
unmutedAudioTracks,
|
|
935
979
|
}
|
|
936
980
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDR tone curves: which look an HDR project is tone-mapped through when the
|
|
3
|
+
* render produces a standard-range (SDR) file.
|
|
4
|
+
*
|
|
5
|
+
* Background: an HDR project can export its HDR master untouched, an SDR
|
|
6
|
+
* deliverable, or both. Every SDR deliverable goes through one of Montaj's
|
|
7
|
+
* signature 3D LUTs — the curve decides how highlights land once the extra HDR
|
|
8
|
+
* range is folded down.
|
|
9
|
+
*
|
|
10
|
+
* The preview, the proxies and the editor thumbnails ALWAYS use the default
|
|
11
|
+
* curve. This picker changes the exported file only, which is why choosing a
|
|
12
|
+
* non-default curve swaps the modal's honesty line (see `honestyLine` below).
|
|
13
|
+
*
|
|
14
|
+
* Keep ids in sync with the `curves` keys in montaj_assets/luts/looks.json,
|
|
15
|
+
* which is what the render pipeline validates against.
|
|
16
|
+
*/
|
|
17
|
+
export type SdrCurve = 'vivid1' | 'vivid1-neutral'
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_SDR_CURVE: SdrCurve = 'vivid1'
|
|
20
|
+
|
|
21
|
+
export interface SdrCurveInfo {
|
|
22
|
+
id: SdrCurve
|
|
23
|
+
label: string
|
|
24
|
+
/** One-line summary shown under the label. Plain English, no jargon. */
|
|
25
|
+
blurb: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SDR_CURVES: SdrCurveInfo[] = [
|
|
29
|
+
{
|
|
30
|
+
id: 'vivid1',
|
|
31
|
+
label: 'Montaj Vivid',
|
|
32
|
+
blurb: 'The signature look. Rich color with highlights rolled off gently, and what your preview shows.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'vivid1-neutral',
|
|
36
|
+
label: 'Neutral brights',
|
|
37
|
+
blurb: 'The same color, with flatter bright areas. Calmer on skies, windows and lit skin.',
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** Descriptor for a curve id, or the default curve's when the id is unknown. */
|
|
42
|
+
export function sdrCurveInfo(id: string | undefined): SdrCurveInfo {
|
|
43
|
+
return SDR_CURVES.find(c => c.id === id) ?? SDR_CURVES[0]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The line under the curve picker. On the default curve it tells the user how
|
|
48
|
+
* each export relates to what they are looking at; on any other curve it
|
|
49
|
+
* becomes a caveat, because the preview is always Montaj Vivid and the export
|
|
50
|
+
* no longer matches it.
|
|
51
|
+
*/
|
|
52
|
+
export function honestyLine(curve: string | undefined): string {
|
|
53
|
+
return (curve ?? DEFAULT_SDR_CURVE) === DEFAULT_SDR_CURVE
|
|
54
|
+
? 'SDR export matches this preview; HDR export adds highlight range on HDR displays.'
|
|
55
|
+
: 'Heads up: this export will not match your preview. The preview always uses Montaj Vivid, so the curve you picked shows up in the exported file only.'
|
|
56
|
+
}
|