@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
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
- * - `'right'` (default) a sidebar column to the right of the preview/timeline.
495
- * The historical Montaj-local layout; preferred when horizontal space is ample.
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 — right sidebar column (assetsPlacement: 'right', the default /
565
- Montaj-local layout). The host's panel manages its own scroll. */}
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
- {(adapter.listVersionHistory || slots?.runHistory) && (
574
- <div className="w-48 shrink-0 border-l border-[var(--editor-border)] bg-[var(--editor-surface)] flex flex-col overflow-hidden">
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
- <div ref={wrapRef} className="absolute inset-0 pointer-events-none overflow-hidden">
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
- // Set video clip volume via GainNode (supports amplification > 1.0).
233
- // Muted clips get gain 0; unmuted clips get the clip's volume value.
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 = ensureVideoGain(slot)
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.play().catch(() => {})
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 ~1s before end
647
- const timeLeft = outPoint - video.currentTime
648
- if (timeLeft < 1.0) {
649
- const nextIdx = activeIdxRef.current + 1
650
- if (nextIdx < clips.length && clips[nextIdx].src) {
651
- const inactiveVideo = slot === 0 ? video1Ref.current : video0Ref.current
652
- const nextSrc = fileUrlRef.current(playbackSrcFor(clips[nextIdx]))
653
- if (inactiveVideo && preloadSrcRef.current !== nextSrc) {
654
- preloadSrcRef.current = nextSrc
655
- inactiveVideo.src = nextSrc
656
- inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
657
- const inactiveSlot = (1 - slot) as 0 | 1
658
- const nextGain = ensureVideoGain(inactiveSlot)
659
- if (nextGain) nextGain.gain.value = clips[nextIdx].muted ? 0 : (clips[nextIdx].volume ?? 1)
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.play().catch(() => {})
743
+ playSoon(nextVideo)
711
744
  }
712
745
 
713
746
  activeSlotRef.current = nextSlot