@bycrux/editor 0.6.2 → 0.6.4
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
CHANGED
package/src/types.ts
CHANGED
|
@@ -491,13 +491,17 @@ export interface VideoEditorProps<P extends Project = Project> {
|
|
|
491
491
|
onBackToSetup?: () => void
|
|
492
492
|
/**
|
|
493
493
|
* Where the host's `slots.assetsPanel` is placed in the review layout:
|
|
494
|
-
* - `'
|
|
495
|
-
*
|
|
494
|
+
* - `'sidebar'` — stacked inside the right-hand version/run-history rail, below
|
|
495
|
+
* it, sharing one column. This is the historical Montaj-local OS layout
|
|
496
|
+
* (versions on top, assets right below) and what the desktop UI uses.
|
|
497
|
+
* - `'right'` — its own dedicated sidebar column to the LEFT of the version
|
|
498
|
+
* rail (two separate columns). Preferred only when horizontal space is ample
|
|
499
|
+
* and the host wants assets visually distinct from versions.
|
|
496
500
|
* - `'bottom'` — a full-width region stacked below the editor. Used by hosts with
|
|
497
501
|
* constrained width (e.g. the Hub editor) where vertical stacking reads better.
|
|
498
502
|
* The host chooses per deployment; the package defaults to `'right'`.
|
|
499
503
|
*/
|
|
500
|
-
assetsPlacement?: 'right' | 'bottom'
|
|
504
|
+
assetsPlacement?: 'sidebar' | 'right' | 'bottom'
|
|
501
505
|
|
|
502
506
|
// ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
|
|
503
507
|
// The clip/audio inspector and the subcut-regeneration tool read host-only
|
|
@@ -561,17 +561,23 @@ function ReviewSurface<P extends Project>({
|
|
|
561
561
|
</div>
|
|
562
562
|
</div>
|
|
563
563
|
|
|
564
|
-
{/* Assets —
|
|
565
|
-
|
|
564
|
+
{/* Assets — dedicated separate column to the LEFT of the version rail
|
|
565
|
+
(assetsPlacement: 'right', two distinct columns). The Montaj-local OS
|
|
566
|
+
layout uses 'sidebar' instead (stacked into the version rail below).
|
|
567
|
+
The host's panel manages its own scroll. */}
|
|
566
568
|
{assetsPlacement === 'right' && slots?.assetsPanel && (
|
|
567
569
|
<div className="w-72 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
|
|
568
570
|
{slots.assetsPanel}
|
|
569
571
|
</div>
|
|
570
572
|
)}
|
|
571
573
|
|
|
572
|
-
{/* Right rail — version history + run history slot
|
|
573
|
-
|
|
574
|
-
|
|
574
|
+
{/* Right rail — version history + run history slot, and (in 'sidebar'
|
|
575
|
+
placement) the assets panel stacked beneath them in the SAME column.
|
|
576
|
+
This is the historical Montaj-local OS layout: versions on top, assets
|
|
577
|
+
right below, one column — not a separate assets column. */}
|
|
578
|
+
{(adapter.listVersionHistory || slots?.runHistory ||
|
|
579
|
+
(assetsPlacement === 'sidebar' && slots?.assetsPanel)) && (
|
|
580
|
+
<div className={`${assetsPlacement === 'sidebar' ? 'w-56' : 'w-48'} shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden`}>
|
|
575
581
|
{adapter.listVersionHistory && (
|
|
576
582
|
<VersionPanel versions={versions} restoring={restoring} onRestore={handleRestoreVersion} />
|
|
577
583
|
)}
|
|
@@ -579,6 +585,14 @@ function ReviewSurface<P extends Project>({
|
|
|
579
585
|
RunSnapshot / project.history are host-only types — the package never
|
|
580
586
|
reads them. When absent nothing is rendered. */}
|
|
581
587
|
{slots?.runHistory}
|
|
588
|
+
{/* Assets stacked below versions/runs (assetsPlacement: 'sidebar'). The
|
|
589
|
+
host's panel manages its own scroll; flex-1 lets it take the
|
|
590
|
+
remaining rail height. A top border separates it from the runs. */}
|
|
591
|
+
{assetsPlacement === 'sidebar' && slots?.assetsPanel && (
|
|
592
|
+
<div className="flex-1 min-h-0 overflow-hidden border-t border-[var(--editor-border)] flex flex-col">
|
|
593
|
+
{slots.assetsPanel}
|
|
594
|
+
</div>
|
|
595
|
+
)}
|
|
582
596
|
</div>
|
|
583
597
|
)}
|
|
584
598
|
</div>
|
|
@@ -63,7 +63,13 @@ export default function CaptionPreview({ track, currentTime, fps, compileOverlay
|
|
|
63
63
|
: null
|
|
64
64
|
|
|
65
65
|
return (
|
|
66
|
-
<
|
|
66
|
+
// zIndex 45 keeps captions above the active <video> (z 1) and overlay items
|
|
67
|
+
// (z `trackIdx + 12`, ≈12–20) — mirroring the final render, where the caption
|
|
68
|
+
// track composites on top — while staying below the editing affordances
|
|
69
|
+
// (selection handles z 50, play button z 100). Without an explicit z-index the
|
|
70
|
+
// root sits at `auto`, so the opaque active video paints over it and captions
|
|
71
|
+
// never appear in the preview.
|
|
72
|
+
<div ref={wrapRef} className="absolute inset-0 pointer-events-none overflow-hidden" style={{ zIndex: 45 }}>
|
|
67
73
|
<OverlayErrorBoundary label={`caption: ${track.style}`} resetKey={track.style}>
|
|
68
74
|
{element && scale !== null && (
|
|
69
75
|
<div style={{
|
|
@@ -136,6 +136,8 @@ export default function PreviewPlayer({
|
|
|
136
136
|
// and the Web Audio createMediaElementSource graph outputs silence. R2
|
|
137
137
|
// sends Access-Control-Allow-Origin, so anonymous CORS keeps it audible.
|
|
138
138
|
crossOrigin="anonymous"
|
|
139
|
+
// Fetch enough to render the seeked poster frame on load (before play).
|
|
140
|
+
preload="auto"
|
|
139
141
|
onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
|
|
140
142
|
onTimeUpdate={() => { if (activeSlotRef.current === 0) handleTimeUpdate() }}
|
|
141
143
|
onEnded={() => { if (activeSlotRef.current === 0) handleEnded() }}
|
|
@@ -150,6 +152,7 @@ export default function PreviewPlayer({
|
|
|
150
152
|
// See slot 0: anonymous CORS so R2 cross-origin clips aren't tainted
|
|
151
153
|
// (which would mute the Web Audio graph).
|
|
152
154
|
crossOrigin="anonymous"
|
|
155
|
+
preload="auto"
|
|
153
156
|
onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
|
|
154
157
|
onTimeUpdate={() => { if (activeSlotRef.current === 1) handleTimeUpdate() }}
|
|
155
158
|
onEnded={() => { if (activeSlotRef.current === 1) handleEnded() }}
|
|
@@ -196,6 +196,12 @@ export function useVideoPlayback(
|
|
|
196
196
|
* deferred play() is not autoplay-blocked.
|
|
197
197
|
*/
|
|
198
198
|
function playFromGesture(video: HTMLVideoElement) {
|
|
199
|
+
// Wire this slot to Web Audio on the first play (not at load) so the paused
|
|
200
|
+
// poster frame can render. This also creates the shared AudioContext inside
|
|
201
|
+
// the gesture, so the resume() below is gesture-credited.
|
|
202
|
+
ensureVideoGain(activeSlotRef.current)
|
|
203
|
+
const cur = clips[activeIdxRef.current]
|
|
204
|
+
if (cur) applyClipVolume(cur)
|
|
199
205
|
const w = window as Window & MontajWindow
|
|
200
206
|
const ctx = w.__montajSharedCtx
|
|
201
207
|
if (ctx && ctx.state === 'suspended') {
|
|
@@ -208,6 +214,18 @@ export function useVideoPlayback(
|
|
|
208
214
|
}
|
|
209
215
|
}
|
|
210
216
|
|
|
217
|
+
// Start playback on a slot during an automated clip-boundary switch (context
|
|
218
|
+
// already running — no gesture needed). If the slot isn't buffered yet (a slow
|
|
219
|
+
// non-faststart HEVC tail-moov fetch can still be in flight), play() may reject
|
|
220
|
+
// with AbortError; retry on `canplay` so the switch never dead-stops at the cut.
|
|
221
|
+
function playSoon(video: HTMLVideoElement) {
|
|
222
|
+
const p = video.play()
|
|
223
|
+
if (p) p.catch(() => {
|
|
224
|
+
const onCanPlay = () => { video.removeEventListener('canplay', onCanPlay); video.play().catch(() => {}) }
|
|
225
|
+
video.addEventListener('canplay', onCanPlay)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
211
229
|
function ensureVideoGain(slot: 0 | 1): GainNode | null {
|
|
212
230
|
if (videoGainRef.current[slot]) return videoGainRef.current[slot]
|
|
213
231
|
const video = slot === 0 ? video0Ref.current : video1Ref.current
|
|
@@ -229,11 +247,21 @@ export function useVideoPlayback(
|
|
|
229
247
|
return v.__montajGain ?? null
|
|
230
248
|
}
|
|
231
249
|
|
|
232
|
-
//
|
|
233
|
-
//
|
|
250
|
+
// Existing gain for a slot WITHOUT wiring it. Wiring (createMediaElementSource)
|
|
251
|
+
// is deferred to the first play gesture so the paused poster frame can render —
|
|
252
|
+
// a <video> wired to a suspended AudioContext produces no frames at all.
|
|
253
|
+
function getVideoGain(slot: 0 | 1): GainNode | null {
|
|
254
|
+
const v = (slot === 0 ? video0Ref.current : video1Ref.current) as MontajVideoElement | null
|
|
255
|
+
return (v && v.__montajGain) ?? null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Set video clip volume via GainNode (supports amplification > 1.0). Muted clips
|
|
259
|
+
// get gain 0; unmuted clips get the clip's volume value. No-op until the slot is
|
|
260
|
+
// wired (first play) — there's no audio to control on a paused poster, and
|
|
261
|
+
// wiring here would gate the poster frame on the suspended context.
|
|
234
262
|
function applyClipVolume(clip: { muted?: boolean; volume?: number }) {
|
|
235
263
|
const slot = activeSlotRef.current
|
|
236
|
-
const gain =
|
|
264
|
+
const gain = getVideoGain(slot)
|
|
237
265
|
if (gain) gain.gain.value = clip.muted ? 0 : (clip.volume ?? 1)
|
|
238
266
|
}
|
|
239
267
|
|
|
@@ -553,7 +581,7 @@ export function useVideoPlayback(
|
|
|
553
581
|
if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = effectiveInPoint(nc) }
|
|
554
582
|
const gain = ensureVideoGain(ns)
|
|
555
583
|
if (gain) gain.gain.value = nc.muted ? 0 : (nc.volume ?? 1)
|
|
556
|
-
nv
|
|
584
|
+
playSoon(nv)
|
|
557
585
|
}
|
|
558
586
|
void (activeSlotRef.current === 0 ? video0Ref.current : video1Ref.current)?.pause()
|
|
559
587
|
activeSlotRef.current = ns
|
|
@@ -643,21 +671,26 @@ export function useVideoPlayback(
|
|
|
643
671
|
const clipInPoint = effectiveInPoint(clip)
|
|
644
672
|
const outPoint = effectiveOutPoint(clip) ?? clip.end - clip.start + clipInPoint
|
|
645
673
|
|
|
646
|
-
// Preload next clip into inactive slot
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
674
|
+
// Preload the next clip into the inactive slot as early as possible. The
|
|
675
|
+
// source files are large 4K 10-bit HEVC with the moov atom at the END of the
|
|
676
|
+
// file (not web-faststart), so the browser needs a slow tail range-fetch to
|
|
677
|
+
// index and seek before it can decode. The old "~1s before end" lead was far
|
|
678
|
+
// too short: at a cross-source cut the next slot wasn't ready and play()
|
|
679
|
+
// stalled, freezing playback at the boundary. (Same-source cuts hid the bug —
|
|
680
|
+
// the moov was already cached from the active slot.) Give the load the whole
|
|
681
|
+
// current clip as runway instead; the preloadSrcRef guard keeps it idempotent
|
|
682
|
+
// and a scrub clears it.
|
|
683
|
+
const nextIdx = activeIdxRef.current + 1
|
|
684
|
+
if (nextIdx < clips.length && clips[nextIdx].src) {
|
|
685
|
+
const inactiveVideo = slot === 0 ? video1Ref.current : video0Ref.current
|
|
686
|
+
const nextSrc = fileUrlRef.current(playbackSrcFor(clips[nextIdx]))
|
|
687
|
+
if (inactiveVideo && preloadSrcRef.current !== nextSrc) {
|
|
688
|
+
preloadSrcRef.current = nextSrc
|
|
689
|
+
inactiveVideo.src = nextSrc
|
|
690
|
+
inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
|
|
691
|
+
const inactiveSlot = (1 - slot) as 0 | 1
|
|
692
|
+
const nextGain = ensureVideoGain(inactiveSlot)
|
|
693
|
+
if (nextGain) nextGain.gain.value = clips[nextIdx].muted ? 0 : (clips[nextIdx].volume ?? 1)
|
|
661
694
|
}
|
|
662
695
|
}
|
|
663
696
|
|
|
@@ -707,7 +740,7 @@ export function useVideoPlayback(
|
|
|
707
740
|
}
|
|
708
741
|
const nextGain = ensureVideoGain(nextSlot)
|
|
709
742
|
if (nextGain) nextGain.gain.value = next.muted ? 0 : (next.volume ?? 1)
|
|
710
|
-
nextVideo
|
|
743
|
+
playSoon(nextVideo)
|
|
711
744
|
}
|
|
712
745
|
|
|
713
746
|
activeSlotRef.current = nextSlot
|