@bycrux/editor 0.5.3 → 0.6.0
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 +1 -1
- package/src/crop/VideoSourceCropOverlay.tsx +249 -0
- package/src/crop/__tests__/VideoSourceCropOverlay.test.tsx +105 -0
- package/src/crop/__tests__/crop-math.test.ts +15 -0
- package/src/schema.ts +6 -1
- package/src/types.ts +9 -0
- package/src/video/CaptionRegenModal.tsx +11 -11
- package/src/video/RenderModal.tsx +21 -21
- package/src/video/VersionPanel.tsx +11 -11
- package/src/video/VideoEditor.tsx +128 -37
- package/src/video/preview/PreviewPlayer.tsx +42 -4
- package/src/video/preview/__tests__/sourceCropStyle.test.ts +61 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +52 -0
- package/src/video/preview/sourceCropStyle.ts +70 -0
- package/src/video/preview/useVideoPlayback.ts +55 -14
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Pure CSS-math for reflecting a clip's `sourceCrop` in the preview <video>.
|
|
2
|
+
//
|
|
3
|
+
// Render (montaj_assets/render/encode-segment.js:buildVideoItemFilterParts)
|
|
4
|
+
// applies sourceCrop as an ffmpeg `crop=cw:ch:cx:cy` BEFORE the
|
|
5
|
+
// `scale=...:force_original_aspect_ratio=decrease` + `pad` step — i.e. it crops
|
|
6
|
+
// the source to the sub-rect, then CONTAIN-fits that sub-rect into the output
|
|
7
|
+
// frame (letterboxing if the sub-rect's aspect differs from the frame).
|
|
8
|
+
//
|
|
9
|
+
// This helper produces the equivalent CSS for a <video> sitting in an
|
|
10
|
+
// overflow-hidden frame box (the player surface, fixed at the output aspect):
|
|
11
|
+
// size the video larger than the frame and translate it so only the crop
|
|
12
|
+
// sub-rect shows, scaled to contain within the frame. All values are ratios of
|
|
13
|
+
// the frame's own dimensions, so the result is frame-pixel-size-independent and
|
|
14
|
+
// can be expressed purely in `%`.
|
|
15
|
+
//
|
|
16
|
+
// Requires the source's intrinsic pixel dims (to know the crop region's aspect
|
|
17
|
+
// ratio). Without them — or without a crop — returns null and the caller keeps
|
|
18
|
+
// the default `object-contain` full-frame behavior.
|
|
19
|
+
|
|
20
|
+
import type { CSSProperties } from 'react'
|
|
21
|
+
|
|
22
|
+
export interface SourceCropInput {
|
|
23
|
+
crop: { x: number; y: number; w: number; h: number }
|
|
24
|
+
sourceWidth: number
|
|
25
|
+
sourceHeight: number
|
|
26
|
+
frameWidth: number
|
|
27
|
+
frameHeight: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sourceCropVideoStyle(input: SourceCropInput): CSSProperties | null {
|
|
31
|
+
const { crop, sourceWidth, sourceHeight, frameWidth, frameHeight } = input
|
|
32
|
+
if (!sourceWidth || !sourceHeight || !frameWidth || !frameHeight) return null
|
|
33
|
+
if (!crop || crop.w <= 0 || crop.h <= 0) return null
|
|
34
|
+
// A full-frame crop (the default) needs no special handling.
|
|
35
|
+
if (crop.x === 0 && crop.y === 0 && crop.w === 1 && crop.h === 1) return null
|
|
36
|
+
|
|
37
|
+
const frameAspect = frameWidth / frameHeight
|
|
38
|
+
const cropAspect = (sourceWidth * crop.w) / (sourceHeight * crop.h)
|
|
39
|
+
|
|
40
|
+
// Contain-fit the crop region into the frame → its displayed size as a ratio
|
|
41
|
+
// of the frame's own dimensions.
|
|
42
|
+
let cropWRatio: number
|
|
43
|
+
let cropHRatio: number
|
|
44
|
+
if (cropAspect >= frameAspect) {
|
|
45
|
+
cropWRatio = 1
|
|
46
|
+
cropHRatio = frameAspect / cropAspect
|
|
47
|
+
} else {
|
|
48
|
+
cropHRatio = 1
|
|
49
|
+
cropWRatio = cropAspect / frameAspect
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The full (uncropped) video's displayed size as a ratio of the frame.
|
|
53
|
+
const videoWRatio = cropWRatio / crop.w
|
|
54
|
+
const videoHRatio = cropHRatio / crop.h
|
|
55
|
+
|
|
56
|
+
// Position the video so the crop sub-rect's top-left aligns, then center the
|
|
57
|
+
// contained region within the frame (the letterbox offset).
|
|
58
|
+
const leftRatio = (1 - cropWRatio) / 2 - crop.x * videoWRatio
|
|
59
|
+
const topRatio = (1 - cropHRatio) / 2 - crop.y * videoHRatio
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
position: 'absolute',
|
|
63
|
+
width: `${videoWRatio * 100}%`,
|
|
64
|
+
height: `${videoHRatio * 100}%`,
|
|
65
|
+
left: `${leftRatio * 100}%`,
|
|
66
|
+
top: `${topRatio * 100}%`,
|
|
67
|
+
objectFit: 'fill',
|
|
68
|
+
maxWidth: 'none',
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -26,8 +26,47 @@ interface MontajVideoElement extends HTMLVideoElement {
|
|
|
26
26
|
* Note: `nobg_src` is the ProRes 4444 render-only artifact and is NEVER
|
|
27
27
|
* loaded into a `<video>` element — browsers can't decode ProRes.
|
|
28
28
|
*/
|
|
29
|
-
function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string }): string {
|
|
30
|
-
return clip.nobg_preview_src ?? clip.src ?? ''
|
|
29
|
+
function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string; normalizedSrc?: string }): string {
|
|
30
|
+
return clip.nobg_preview_src ?? clip.normalizedSrc ?? clip.src ?? ''
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The inPoint the preview should SEEK to for this clip, accounting for the
|
|
35
|
+
* normalizedSrc cache rebase.
|
|
36
|
+
*
|
|
37
|
+
* A `normalizedSrc` cache covers exactly [inPoint, outPoint] of the original
|
|
38
|
+
* and STARTS AT 0 (it is only `outPoint - inPoint` seconds long). When
|
|
39
|
+
* `playbackSrcFor` chooses that cache as the playback src, seeking to the
|
|
40
|
+
* original `clip.inPoint` (e.g. 496.92) would land far past the end of the
|
|
41
|
+
* short file → the browser clamps to EOF and the preview freezes on the last
|
|
42
|
+
* frame. So when the cache is the chosen src, the effective inPoint is 0 and
|
|
43
|
+
* the window maps to [0, outPoint - inPoint].
|
|
44
|
+
*
|
|
45
|
+
* This mirrors render's `collectAllItems` (montaj_assets/render/render.js),
|
|
46
|
+
* which substitutes `item.normalizedSrc` as the src AND rebases inPoint to 0.
|
|
47
|
+
*
|
|
48
|
+
* The rebase applies ONLY when the cache is actually the chosen src.
|
|
49
|
+
* `nobg_preview_src` takes precedence in `playbackSrcFor` and is NOT a window
|
|
50
|
+
* cache (it covers the full source), so it keeps the original inPoint — exactly
|
|
51
|
+
* as render's nobg path does.
|
|
52
|
+
*/
|
|
53
|
+
export function effectiveInPoint(clip: { inPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number {
|
|
54
|
+
const usingNormalizedCache = !clip.nobg_preview_src && !!clip.normalizedSrc
|
|
55
|
+
return usingNormalizedCache ? 0 : (clip.inPoint ?? 0)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The outPoint in the loaded src's own timeline. For a normalizedSrc cache the
|
|
60
|
+
* stored `clip.outPoint` is in ORIGINAL-source coordinates (e.g. 514.92) while
|
|
61
|
+
* the cache plays in [0, outPoint - inPoint]; the boundary/loop checks compare
|
|
62
|
+
* against `video.currentTime` (cache time), so the outPoint must be rebased to
|
|
63
|
+
* the window length. Returns undefined when no outPoint is stored, so callers
|
|
64
|
+
* keep their existing fallback (clip.end - clip.start + effectiveInPoint).
|
|
65
|
+
*/
|
|
66
|
+
export function effectiveOutPoint(clip: { inPoint?: number; outPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number | undefined {
|
|
67
|
+
if (clip.outPoint == null) return undefined
|
|
68
|
+
const usingNormalizedCache = !clip.nobg_preview_src && !!clip.normalizedSrc
|
|
69
|
+
return usingNormalizedCache ? clip.outPoint - (clip.inPoint ?? 0) : clip.outPoint
|
|
31
70
|
}
|
|
32
71
|
|
|
33
72
|
export function useVideoPlayback(
|
|
@@ -437,7 +476,7 @@ export function useVideoPlayback(
|
|
|
437
476
|
setActiveSlot(0)
|
|
438
477
|
preloadSrcRef.current = ''
|
|
439
478
|
video.src = fileUrlRef.current(playbackSrcFor(clips[0]))
|
|
440
|
-
video.currentTime = clips[0]
|
|
479
|
+
video.currentTime = effectiveInPoint(clips[0])
|
|
441
480
|
applyClipVolume(clips[0])
|
|
442
481
|
// Clear inactive slot
|
|
443
482
|
const inactive = getInactiveVideo()
|
|
@@ -488,7 +527,7 @@ export function useVideoPlayback(
|
|
|
488
527
|
activeIdxRef.current = ni
|
|
489
528
|
if (nv) {
|
|
490
529
|
const src = fileUrlRef.current(playbackSrcFor(nc))
|
|
491
|
-
if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = nc
|
|
530
|
+
if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = effectiveInPoint(nc) }
|
|
492
531
|
const gain = ensureVideoGain(ns)
|
|
493
532
|
if (gain) gain.gain.value = nc.muted ? 0 : (nc.volume ?? 1)
|
|
494
533
|
nv.play().catch(() => {})
|
|
@@ -542,9 +581,10 @@ export function useVideoPlayback(
|
|
|
542
581
|
if (inactive) { inactive.pause(); inactive.removeAttribute('src') }
|
|
543
582
|
}
|
|
544
583
|
applyClipVolume(clip)
|
|
545
|
-
const inPoint = clip
|
|
546
|
-
|
|
547
|
-
|
|
584
|
+
const inPoint = effectiveInPoint(clip)
|
|
585
|
+
const clipOutPoint = effectiveOutPoint(clip)
|
|
586
|
+
if (clip.loop && clipOutPoint != null) {
|
|
587
|
+
const loopDur = clipOutPoint - inPoint
|
|
548
588
|
const elapsed = currentTime - clip.start
|
|
549
589
|
const loops = Math.floor(elapsed / loopDur)
|
|
550
590
|
loopOffsetRef.current = loops * loopDur
|
|
@@ -577,7 +617,8 @@ export function useVideoPlayback(
|
|
|
577
617
|
const clip = clips[activeIdxRef.current]
|
|
578
618
|
if (!clip) return
|
|
579
619
|
|
|
580
|
-
const
|
|
620
|
+
const clipInPoint = effectiveInPoint(clip)
|
|
621
|
+
const outPoint = effectiveOutPoint(clip) ?? clip.end - clip.start + clipInPoint
|
|
581
622
|
|
|
582
623
|
// Preload next clip into inactive slot ~1s before end
|
|
583
624
|
const timeLeft = outPoint - video.currentTime
|
|
@@ -589,7 +630,7 @@ export function useVideoPlayback(
|
|
|
589
630
|
if (inactiveVideo && preloadSrcRef.current !== nextSrc) {
|
|
590
631
|
preloadSrcRef.current = nextSrc
|
|
591
632
|
inactiveVideo.src = nextSrc
|
|
592
|
-
inactiveVideo.currentTime = clips[nextIdx]
|
|
633
|
+
inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
|
|
593
634
|
const inactiveSlot = (1 - slot) as 0 | 1
|
|
594
635
|
const nextGain = ensureVideoGain(inactiveSlot)
|
|
595
636
|
if (nextGain) nextGain.gain.value = clips[nextIdx].muted ? 0 : (clips[nextIdx].volume ?? 1)
|
|
@@ -599,12 +640,12 @@ export function useVideoPlayback(
|
|
|
599
640
|
|
|
600
641
|
if (video.currentTime >= outPoint) {
|
|
601
642
|
if (clip.loop) {
|
|
602
|
-
const projectT = clip.start + loopOffsetRef.current + (video.currentTime -
|
|
643
|
+
const projectT = clip.start + loopOffsetRef.current + (video.currentTime - clipInPoint)
|
|
603
644
|
if (projectT < clip.end) {
|
|
604
645
|
// Still within the clip's project window — loop the source video
|
|
605
|
-
const loopDur = outPoint -
|
|
646
|
+
const loopDur = outPoint - clipInPoint
|
|
606
647
|
loopOffsetRef.current += loopDur
|
|
607
|
-
video.currentTime =
|
|
648
|
+
video.currentTime = clipInPoint
|
|
608
649
|
return
|
|
609
650
|
}
|
|
610
651
|
// Project end reached — fall through to the stop/next-clip logic below
|
|
@@ -639,7 +680,7 @@ export function useVideoPlayback(
|
|
|
639
680
|
const nextSrc = fileUrlRef.current(playbackSrcFor(next))
|
|
640
681
|
if (preloadSrcRef.current !== nextSrc) {
|
|
641
682
|
nextVideo.src = nextSrc
|
|
642
|
-
nextVideo.currentTime = next
|
|
683
|
+
nextVideo.currentTime = effectiveInPoint(next)
|
|
643
684
|
}
|
|
644
685
|
const nextGain = ensureVideoGain(nextSlot)
|
|
645
686
|
if (nextGain) nextGain.gain.value = next.muted ? 0 : (next.volume ?? 1)
|
|
@@ -674,7 +715,7 @@ export function useVideoPlayback(
|
|
|
674
715
|
return
|
|
675
716
|
}
|
|
676
717
|
|
|
677
|
-
const t = clip.start + loopOffsetRef.current + (video.currentTime -
|
|
718
|
+
const t = clip.start + loopOffsetRef.current + (video.currentTime - clipInPoint)
|
|
678
719
|
|
|
679
720
|
// For looping clips, stop when project time reaches clip.end mid-loop
|
|
680
721
|
if (clip.loop && t >= clip.end) {
|