@bycrux/editor 0.6.1 → 0.6.3

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.1",
3
+ "version": "0.6.3",
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-64' : '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>
@@ -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() }}
@@ -185,6 +185,35 @@ export function useVideoPlayback(
185
185
  }
186
186
  }
187
187
 
188
+ /**
189
+ * Start playback on a wired <video> from a user gesture. Video frame
190
+ * production is gated on the shared AudioContext clock running, and the
191
+ * context is created suspended inside a useEffect — so the FIRST play after a
192
+ * hard refresh fires while resume() is still pending and renders no frames
193
+ * until the next seek. resume() is gesture-credited at the synchronous call
194
+ * site here, so wait for it to actually resolve to 'running' before calling
195
+ * play(); the page already has sticky activation from the click, so the
196
+ * deferred play() is not autoplay-blocked.
197
+ */
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)
205
+ const w = window as Window & MontajWindow
206
+ const ctx = w.__montajSharedCtx
207
+ if (ctx && ctx.state === 'suspended') {
208
+ ctx.resume().then(
209
+ () => { void video.play().catch(() => {}) },
210
+ () => { void video.play().catch(() => {}) },
211
+ )
212
+ } else {
213
+ void video.play().catch(() => {})
214
+ }
215
+ }
216
+
188
217
  function ensureVideoGain(slot: 0 | 1): GainNode | null {
189
218
  if (videoGainRef.current[slot]) return videoGainRef.current[slot]
190
219
  const video = slot === 0 ? video0Ref.current : video1Ref.current
@@ -206,11 +235,21 @@ export function useVideoPlayback(
206
235
  return v.__montajGain ?? null
207
236
  }
208
237
 
209
- // Set video clip volume via GainNode (supports amplification > 1.0).
210
- // Muted clips get gain 0; unmuted clips get the clip's volume value.
238
+ // Existing gain for a slot WITHOUT wiring it. Wiring (createMediaElementSource)
239
+ // is deferred to the first play gesture so the paused poster frame can render —
240
+ // a <video> wired to a suspended AudioContext produces no frames at all.
241
+ function getVideoGain(slot: 0 | 1): GainNode | null {
242
+ const v = (slot === 0 ? video0Ref.current : video1Ref.current) as MontajVideoElement | null
243
+ return (v && v.__montajGain) ?? null
244
+ }
245
+
246
+ // Set video clip volume via GainNode (supports amplification > 1.0). Muted clips
247
+ // get gain 0; unmuted clips get the clip's volume value. No-op until the slot is
248
+ // wired (first play) — there's no audio to control on a paused poster, and
249
+ // wiring here would gate the poster frame on the suspended context.
211
250
  function applyClipVolume(clip: { muted?: boolean; volume?: number }) {
212
251
  const slot = activeSlotRef.current
213
- const gain = ensureVideoGain(slot)
252
+ const gain = getVideoGain(slot)
214
253
  if (gain) gain.gain.value = clip.muted ? 0 : (clip.volume ?? 1)
215
254
  }
216
255
 
@@ -447,7 +486,7 @@ export function useVideoPlayback(
447
486
  }
448
487
  const video = getActiveVideo()
449
488
  if (!video) return
450
- if (video.paused) { video.play().catch(() => {}) } else { video.pause() }
489
+ if (video.paused) { playFromGesture(video) } else { video.pause() }
451
490
  }
452
491
  }
453
492
  document.addEventListener('keydown', onKeyDown)
@@ -815,7 +854,7 @@ export function useVideoPlayback(
815
854
  }
816
855
  const video = getActiveVideo()
817
856
  if (!video) return
818
- if (video.paused) { video.play().catch(() => {}) } else { video.pause() }
857
+ if (video.paused) { playFromGesture(video) } else { video.pause() }
819
858
  }
820
859
 
821
860
  return {