@bycrux/editor 0.5.3 → 0.6.1

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.
@@ -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].inPoint ?? 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.inPoint ?? 0 }
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.inPoint ?? 0
546
- if (clip.loop && clip.outPoint) {
547
- const loopDur = clip.outPoint - inPoint
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 outPoint = clip.outPoint ?? clip.end - clip.start + (clip.inPoint ?? 0)
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].inPoint ?? 0
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 - (clip.inPoint ?? 0))
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 - (clip.inPoint ?? 0)
646
+ const loopDur = outPoint - clipInPoint
606
647
  loopOffsetRef.current += loopDur
607
- video.currentTime = clip.inPoint ?? 0
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.inPoint ?? 0
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 - (clip.inPoint ?? 0))
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) {