@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
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-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
|
-
//
|
|
210
|
-
//
|
|
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 =
|
|
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
|
|
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
|
|
857
|
+
if (video.paused) { playFromGesture(video) } else { video.pause() }
|
|
819
858
|
}
|
|
820
859
|
|
|
821
860
|
return {
|