@bycrux/editor 0.6.3 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,276 @@
1
+ import * as React from 'react'
2
+ import { Crop, RectangleHorizontal, RectangleVertical, Square } from 'lucide-react'
3
+ import type { VisualItem } from '../schema'
4
+ import {
5
+ renderedSourceRect,
6
+ fractionToWrapperPx,
7
+ applyCropHandleDrag,
8
+ aspectLockedCornerResize,
9
+ translateCropPx,
10
+ type CropFraction,
11
+ type CropHandle,
12
+ } from './crop-math'
13
+
14
+ // Crop-proper modal for a tracks[0] video item. Its ONLY job is choosing which
15
+ // rectangle of the SOURCE footage to keep (`sourceCrop`). Positioning and zoom of
16
+ // the clip on the output canvas are NOT here — those are direct-manipulation
17
+ // transforms on the preview (drag + resize handles), the same as overlays.
18
+ //
19
+ // Controls: drag the crop window to move it; drag handles to resize. "Free" gives
20
+ // 8-handle free-form resize; the aspect presets lock the crop's shape (corner
21
+ // handles only, aspect maintained). Commits `sourceCrop` on Apply; Cancel discards.
22
+
23
+ const FREE_HANDLES: CropHandle[] = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se']
24
+ const CORNER_HANDLES: CropHandle[] = ['nw', 'ne', 'sw', 'se']
25
+ const HANDLE_OFFSET: Record<CropHandle, { dx: 0 | 0.5 | 1; dy: 0 | 0.5 | 1; cursor: string }> = {
26
+ nw: { dx: 0, dy: 0, cursor: 'nw-resize' },
27
+ n: { dx: 0.5, dy: 0, cursor: 'n-resize' },
28
+ ne: { dx: 1, dy: 0, cursor: 'ne-resize' },
29
+ w: { dx: 0, dy: 0.5, cursor: 'w-resize' },
30
+ e: { dx: 1, dy: 0.5, cursor: 'e-resize' },
31
+ sw: { dx: 0, dy: 1, cursor: 'sw-resize' },
32
+ s: { dx: 0.5, dy: 1, cursor: 's-resize' },
33
+ se: { dx: 1, dy: 1, cursor: 'se-resize' },
34
+ }
35
+
36
+ const DEFAULT_CROP: CropFraction = { x: 0, y: 0, w: 1, h: 1 }
37
+
38
+ // Shape locks for the crop rectangle (not output framing). `aspect` is the crop's
39
+ // pixel width/height; null = free-form.
40
+ type ShapeKey = 'free' | '1:1' | '16:9' | '9:16'
41
+ const SHAPES: { key: ShapeKey; label: string; aspect: number | null; Icon: typeof Crop }[] = [
42
+ { key: 'free', label: 'Free', aspect: null, Icon: Crop },
43
+ { key: '1:1', label: '1:1', aspect: 1, Icon: Square },
44
+ { key: '16:9', label: '16:9', aspect: 16 / 9, Icon: RectangleHorizontal },
45
+ { key: '9:16', label: '9:16', aspect: 9 / 16, Icon: RectangleVertical },
46
+ ]
47
+
48
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
49
+ function clampFraction(c: CropFraction): CropFraction {
50
+ const x = clamp01(c.x)
51
+ const y = clamp01(c.y)
52
+ return { x, y, w: Math.min(1 - x, Math.max(0, c.w)), h: Math.min(1 - y, Math.max(0, c.h)) }
53
+ }
54
+ const cropCenter = (c: CropFraction) => ({ x: c.x + c.w / 2, y: c.y + c.h / 2 })
55
+
56
+ export type VideoSourceCropModalProps = {
57
+ item: VisualItem
58
+ /** Resolve the clip's preview URL — `(item) => adapter.fileUrl(item.nobg_preview_src ?? item.normalizedSrc ?? item.src)`. */
59
+ resolveSrc: (item: VisualItem) => string
60
+ /** Commit the final crop (source fractions). */
61
+ onApply: (next: CropFraction) => void
62
+ /** Persist source intrinsic dims once known (when the item doesn't carry them). */
63
+ onSrcDimsLoaded: (dims: { width: number; height: number }) => void
64
+ /** Close without committing. */
65
+ onClose: () => void
66
+ }
67
+
68
+ export function VideoSourceCropModal({
69
+ item,
70
+ resolveSrc,
71
+ onApply,
72
+ onSrcDimsLoaded,
73
+ onClose,
74
+ }: VideoSourceCropModalProps) {
75
+ const srcUrl = resolveSrc(item)
76
+
77
+ const [loadedDims, setLoadedDims] = React.useState<{ width: number; height: number } | null>(null)
78
+ const srcDims =
79
+ item.sourceWidth && item.sourceHeight
80
+ ? { width: item.sourceWidth, height: item.sourceHeight }
81
+ : loadedDims
82
+
83
+ const [crop, setCrop] = React.useState<CropFraction>(item.sourceCrop ?? DEFAULT_CROP)
84
+ const [shape, setShape] = React.useState<ShapeKey>('free')
85
+
86
+ const frameRef = React.useRef<HTMLDivElement>(null)
87
+ const [frameBox, setFrameBox] = React.useState<{ w: number; h: number }>({ w: 0, h: 0 })
88
+ React.useEffect(() => {
89
+ const el = frameRef.current
90
+ if (!el) return
91
+ const obs = new ResizeObserver(([entry]) => setFrameBox({ w: entry.contentRect.width, h: entry.contentRect.height }))
92
+ obs.observe(el)
93
+ return () => obs.disconnect()
94
+ }, [])
95
+
96
+ const rendered =
97
+ srcDims && frameBox.w > 0 && frameBox.h > 0
98
+ ? renderedSourceRect({ wrapperW: frameBox.w, wrapperH: frameBox.h, srcWidth: srcDims.width, srcHeight: srcDims.height })
99
+ : null
100
+ const windowPx = rendered ? fractionToWrapperPx({ crop, rendered }) : null
101
+
102
+ const aspectOf = (k: ShapeKey) => SHAPES.find(s => s.key === k)?.aspect ?? null
103
+ const lockedAspect = aspectOf(shape)
104
+
105
+ function selectShape(key: ShapeKey) {
106
+ const aspect = aspectOf(key)
107
+ setShape(key)
108
+ if (aspect == null || !srcDims) return // free: keep current crop
109
+ // Snap the current crop to the locked aspect, keeping its center; derive height
110
+ // from width so the crop's pixel aspect matches, then clamp into bounds.
111
+ const srcAspect = srcDims.width / srcDims.height
112
+ const center = cropCenter(crop)
113
+ let w = crop.w
114
+ let h = (w * srcAspect) / aspect
115
+ if (h > 1) { h = 1; w = (aspect * h) / srcAspect }
116
+ if (w > 1) { w = 1; h = (w * srcAspect) / aspect }
117
+ const x = Math.min(1 - w, Math.max(0, center.x - w / 2))
118
+ const y = Math.min(1 - h, Math.max(0, center.y - h / 2))
119
+ setCrop({ x, y, w, h })
120
+ }
121
+
122
+ // ── Pointer drag: pan (inside window) + resize (handles) ──
123
+ const drag = React.useRef<
124
+ | { kind: 'pan'; startClient: { x: number; y: number }; startCrop: CropFraction }
125
+ | { kind: 'handle'; handle: CropHandle; startClient: { x: number; y: number }; startCrop: CropFraction }
126
+ | null
127
+ >(null)
128
+
129
+ const onPanDown = (e: React.PointerEvent) => {
130
+ e.stopPropagation()
131
+ e.currentTarget.setPointerCapture?.(e.pointerId)
132
+ drag.current = { kind: 'pan', startClient: { x: e.clientX, y: e.clientY }, startCrop: crop }
133
+ }
134
+ const onHandleDown = (handle: CropHandle) => (e: React.PointerEvent) => {
135
+ e.stopPropagation()
136
+ e.currentTarget.setPointerCapture?.(e.pointerId)
137
+ drag.current = { kind: 'handle', handle, startClient: { x: e.clientX, y: e.clientY }, startCrop: crop }
138
+ }
139
+ const onPointerMove = (e: React.PointerEvent) => {
140
+ const d = drag.current
141
+ if (!d || !srcDims) return
142
+ const deltaPx = { x: e.clientX - d.startClient.x, y: e.clientY - d.startClient.y }
143
+ const common = { wrapperW: frameBox.w, wrapperH: frameBox.h, srcWidth: srcDims.width, srcHeight: srcDims.height }
144
+ if (d.kind === 'pan') {
145
+ setCrop(translateCropPx({ crop: d.startCrop, deltaPx, ...common }))
146
+ } else if (lockedAspect != null && (d.handle === 'nw' || d.handle === 'ne' || d.handle === 'sw' || d.handle === 'se')) {
147
+ setCrop(aspectLockedCornerResize({ handle: d.handle, initialCrop: d.startCrop, deltaPx, aspect: lockedAspect, ...common }))
148
+ } else {
149
+ setCrop(clampFraction(applyCropHandleDrag({ handle: d.handle, initialCrop: d.startCrop, deltaPx, ...common })))
150
+ }
151
+ }
152
+ const onPointerUp = () => { drag.current = null }
153
+
154
+ const handles = lockedAspect != null ? CORNER_HANDLES : FREE_HANDLES
155
+ const ready = !!rendered && !!windowPx
156
+
157
+ return (
158
+ <div
159
+ className="fixed inset-0 z-[300] flex items-center justify-center bg-black/70 p-6"
160
+ onPointerDown={onClose}
161
+ >
162
+ <div
163
+ className="flex w-full max-w-3xl flex-col gap-4 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] p-5 shadow-2xl"
164
+ onPointerDown={(e) => e.stopPropagation()}
165
+ >
166
+ <div className="flex items-center justify-between">
167
+ <h2 className="text-sm font-semibold text-[var(--editor-text)]">Crop source</h2>
168
+ <span className="text-xs text-[var(--editor-text)]/50">Pick the part of the footage to keep — position &amp; zoom live on the canvas</span>
169
+ </div>
170
+
171
+ {/* Source frame + crop window */}
172
+ <div
173
+ ref={frameRef}
174
+ className="relative w-full overflow-hidden rounded-lg bg-black"
175
+ style={{ height: '56vh' }}
176
+ onPointerMove={onPointerMove}
177
+ onPointerUp={onPointerUp}
178
+ >
179
+ <video
180
+ src={srcUrl}
181
+ muted
182
+ playsInline
183
+ preload="auto"
184
+ onLoadedMetadata={(e) => {
185
+ const v = e.currentTarget
186
+ const dims = { width: v.videoWidth, height: v.videoHeight }
187
+ setLoadedDims(dims)
188
+ onSrcDimsLoaded(dims)
189
+ }}
190
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }}
191
+ />
192
+
193
+ {ready && windowPx && (
194
+ <>
195
+ <div
196
+ style={{
197
+ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.55)', pointerEvents: 'none',
198
+ clipPath: `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 0,
199
+ ${windowPx.x}px ${windowPx.y}px,
200
+ ${windowPx.x}px ${windowPx.y + windowPx.h}px,
201
+ ${windowPx.x + windowPx.w}px ${windowPx.y + windowPx.h}px,
202
+ ${windowPx.x + windowPx.w}px ${windowPx.y}px,
203
+ ${windowPx.x}px ${windowPx.y}px)`,
204
+ }}
205
+ />
206
+ <div
207
+ onPointerDown={onPanDown}
208
+ style={{
209
+ position: 'absolute', left: windowPx.x, top: windowPx.y, width: windowPx.w, height: windowPx.h,
210
+ outline: '2px solid var(--editor-selection)', cursor: 'move', touchAction: 'none',
211
+ }}
212
+ />
213
+ {handles.map((handle) => {
214
+ const o = HANDLE_OFFSET[handle]
215
+ return (
216
+ <div
217
+ key={handle}
218
+ data-testid={`crop-handle-${handle}`}
219
+ onPointerDown={onHandleDown(handle)}
220
+ style={{
221
+ position: 'absolute',
222
+ left: windowPx.x + o.dx * windowPx.w - 6,
223
+ top: windowPx.y + o.dy * windowPx.h - 6,
224
+ width: 12, height: 12, backgroundColor: '#fff',
225
+ border: '1.5px solid var(--editor-selection)', borderRadius: 2,
226
+ cursor: o.cursor, zIndex: 10, touchAction: 'none',
227
+ }}
228
+ />
229
+ )
230
+ })}
231
+ </>
232
+ )}
233
+ </div>
234
+
235
+ {/* Shape locks (constrain the crop rectangle; not output framing) */}
236
+ <div className="flex items-center gap-2">
237
+ {SHAPES.map(({ key, label, Icon }) => {
238
+ const active = shape === key
239
+ return (
240
+ <button
241
+ key={key}
242
+ onClick={() => selectShape(key)}
243
+ title={key === 'free' ? 'Free-form crop' : `Lock crop to ${label}`}
244
+ className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
245
+ active
246
+ ? 'border-[var(--editor-selection)] bg-[var(--editor-selection)]/15 text-[var(--editor-text)]'
247
+ : 'border-[var(--editor-border)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)]'
248
+ }`}
249
+ >
250
+ <Icon size={14} />
251
+ {label}
252
+ </button>
253
+ )
254
+ })}
255
+ </div>
256
+
257
+ {/* Actions */}
258
+ <div className="flex items-center justify-end gap-2 pt-1">
259
+ <button
260
+ onClick={onClose}
261
+ className="rounded-md px-3 py-1.5 text-xs font-medium text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] transition-colors"
262
+ >
263
+ Cancel
264
+ </button>
265
+ <button
266
+ onClick={() => { onApply(clampFraction(crop)); onClose() }}
267
+ disabled={!ready}
268
+ className="rounded-md bg-[var(--editor-accent)] px-3.5 py-1.5 text-xs font-medium text-[var(--editor-accent-foreground)] hover:opacity-90 disabled:opacity-40 transition-colors"
269
+ >
270
+ Apply crop
271
+ </button>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ )
276
+ }
@@ -1,5 +1,5 @@
1
1
  /// <reference types="vitest/globals" />
2
- import { renderedSourceRect } from '../crop-math'
2
+ import { renderedSourceRect, translateCropPx, cropForAspect, maxZoomForAspect, aspectLockedCornerResize } from '../crop-math'
3
3
  import { fractionToWrapperPx, wrapperPxToFraction } from '../crop-math'
4
4
 
5
5
  describe('renderedSourceRect', () => {
@@ -187,3 +187,96 @@ describe('applyCropHandleDrag', () => {
187
187
  expect(next.h).toBeCloseTo(0.5)
188
188
  })
189
189
  })
190
+
191
+ describe('translateCropPx (pan)', () => {
192
+ const SRC = { wrapperW: 400, wrapperH: 400, srcWidth: 1000, srcHeight: 1000 } // square, no letterbox
193
+
194
+ it('moves the crop by the pixel delta in fractions', () => {
195
+ const next = translateCropPx({
196
+ crop: { x: 0.2, y: 0.2, w: 0.4, h: 0.4 },
197
+ deltaPx: { x: 40, y: -20 }, // 40/400 = +0.1 x, -20/400 = -0.05 y
198
+ ...SRC,
199
+ })
200
+ expect(next.x).toBeCloseTo(0.3)
201
+ expect(next.y).toBeCloseTo(0.15)
202
+ expect(next.w).toBeCloseTo(0.4)
203
+ expect(next.h).toBeCloseTo(0.4)
204
+ })
205
+
206
+ it('clamps so the crop stays fully inside the source (no size change)', () => {
207
+ const next = translateCropPx({
208
+ crop: { x: 0.7, y: 0.7, w: 0.4, h: 0.4 }, // right/bottom edge at 1.1 — already overflowing intent
209
+ deltaPx: { x: 1000, y: 1000 },
210
+ ...SRC,
211
+ })
212
+ // pinned to the far corner: x = 1 - w, y = 1 - h
213
+ expect(next.x).toBeCloseTo(0.6)
214
+ expect(next.y).toBeCloseTo(0.6)
215
+ expect(next.w).toBeCloseTo(0.4)
216
+ expect(next.h).toBeCloseTo(0.4)
217
+ })
218
+ })
219
+
220
+ describe('cropForAspect + maxZoomForAspect', () => {
221
+ it('9:16 crop from a 16:9 source → full height, narrow centered width', () => {
222
+ // source 1920x1080 (aspect 1.778), target 9/16=0.5625 < srcAspect → full height
223
+ const c = cropForAspect({ aspect: 9 / 16, zoom: 1, srcWidth: 1920, srcHeight: 1080 })
224
+ expect(c.h).toBeCloseTo(1)
225
+ // w = aspect/srcAspect = 0.5625 / 1.7778 = 0.3164
226
+ expect(c.w).toBeCloseTo((9 / 16) / (1920 / 1080), 3)
227
+ expect(c.x).toBeCloseTo((1 - c.w) / 2) // centered
228
+ expect(c.y).toBeCloseTo(0)
229
+ })
230
+
231
+ it('16:9 crop from a 16:9 source → the whole frame at zoom 1', () => {
232
+ const c = cropForAspect({ aspect: 16 / 9, zoom: 1, srcWidth: 1920, srcHeight: 1080 })
233
+ expect(c.w).toBeCloseTo(1)
234
+ expect(c.h).toBeCloseTo(1)
235
+ })
236
+
237
+ it('zoom shrinks the crop around its center', () => {
238
+ const c1 = cropForAspect({ aspect: 1, zoom: 1, srcWidth: 1000, srcHeight: 1000 })
239
+ const c2 = cropForAspect({ aspect: 1, zoom: 2, srcWidth: 1000, srcHeight: 1000 })
240
+ expect(c1.w).toBeCloseTo(1)
241
+ expect(c2.w).toBeCloseTo(0.5) // half size at 2×
242
+ expect(c2.x).toBeCloseTo(0.25) // still centered
243
+ expect(c2.y).toBeCloseTo(0.25)
244
+ })
245
+
246
+ it('maxZoomForAspect keeps the crop at/above the 2% floor', () => {
247
+ // 1:1 on a square source: base w=h=1, floor 0.02 → max zoom 50
248
+ const z = maxZoomForAspect({ aspect: 1, srcWidth: 1000, srcHeight: 1000 })
249
+ expect(z).toBeCloseTo(50)
250
+ })
251
+ })
252
+
253
+ describe('aspectLockedCornerResize', () => {
254
+ it('keeps the locked pixel aspect and anchors the opposite corner (se drag)', () => {
255
+ // square source 1000x1000 in a 400x400 frame → rendered fills the frame.
256
+ const next = aspectLockedCornerResize({
257
+ handle: 'se',
258
+ initialCrop: { x: 0.2, y: 0.2, w: 0.2, h: 0.2 }, // nw corner anchored at (0.2,0.2)
259
+ deltaPx: { x: 100, y: 0 }, // drag se corner right
260
+ wrapperW: 400, wrapperH: 400, srcWidth: 1000, srcHeight: 1000,
261
+ aspect: 16 / 9,
262
+ })
263
+ expect(next.x).toBeCloseTo(0.2) // anchor held
264
+ expect(next.y).toBeCloseTo(0.2)
265
+ const pixelAspect = (next.w * 1000) / (next.h * 1000)
266
+ expect(pixelAspect).toBeCloseTo(16 / 9, 2)
267
+ })
268
+
269
+ it('shrinks to fit when the locked box would overflow the source bounds', () => {
270
+ const next = aspectLockedCornerResize({
271
+ handle: 'se',
272
+ initialCrop: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 },
273
+ deltaPx: { x: 100000, y: 0 }, // absurd outward drag
274
+ wrapperW: 400, wrapperH: 400, srcWidth: 1000, srcHeight: 1000,
275
+ aspect: 16 / 9,
276
+ })
277
+ // stays within [0,1] and keeps the aspect
278
+ expect(next.x + next.w).toBeLessThanOrEqual(1.0001)
279
+ expect(next.y + next.h).toBeLessThanOrEqual(1.0001)
280
+ expect((next.w * 1000) / (next.h * 1000)).toBeCloseTo(16 / 9, 2)
281
+ })
282
+ })
@@ -123,3 +123,128 @@ export function applyCropHandleDrag(args: {
123
123
  rendered,
124
124
  })
125
125
  }
126
+
127
+ // Move (pan) the crop window by a pixel delta, keeping its size and clamping so
128
+ // it stays fully inside the rendered source rect. Used by the crop modal's
129
+ // drag-to-reposition.
130
+ export function translateCropPx(args: {
131
+ crop: CropFraction
132
+ deltaPx: { x: number; y: number }
133
+ wrapperW: number
134
+ wrapperH: number
135
+ srcWidth: number
136
+ srcHeight: number
137
+ }): CropFraction {
138
+ const { crop, deltaPx, wrapperW, wrapperH, srcWidth, srcHeight } = args
139
+ const rendered = renderedSourceRect({ wrapperW, wrapperH, srcWidth, srcHeight })
140
+ const px = fractionToWrapperPx({ crop, rendered })
141
+
142
+ const minLeft = rendered.offsetX
143
+ const minTop = rendered.offsetY
144
+ const maxLeft = rendered.offsetX + rendered.width - px.w
145
+ const maxTop = rendered.offsetY + rendered.height - px.h
146
+
147
+ const left = Math.min(maxLeft, Math.max(minLeft, px.x + deltaPx.x))
148
+ const top = Math.min(maxTop, Math.max(minTop, px.y + deltaPx.y))
149
+
150
+ return wrapperPxToFraction({ px: { x: left, y: top, w: px.w, h: px.h }, rendered })
151
+ }
152
+
153
+ // Aspect-locked CORNER resize for the crop modal's shape-lock presets. Because
154
+ // `renderedSourceRect` scales x and y uniformly, a crop's wrapper-px aspect equals
155
+ // its source-pixel aspect — so we can lock the aspect directly in wrapper px. The
156
+ // corner opposite the dragged handle stays fixed; the dragged corner follows the
157
+ // pointer's dominant axis, and the other dimension is derived from `aspect`. Edge
158
+ // handles aren't supported here (the modal hides them when an aspect is locked).
159
+ export function aspectLockedCornerResize(args: {
160
+ handle: 'nw' | 'ne' | 'sw' | 'se'
161
+ initialCrop: CropFraction
162
+ deltaPx: { x: number; y: number }
163
+ wrapperW: number
164
+ wrapperH: number
165
+ srcWidth: number
166
+ srcHeight: number
167
+ aspect: number // target pixel aspect w/h
168
+ }): CropFraction {
169
+ const { handle, initialCrop, deltaPx, wrapperW, wrapperH, srcWidth, srcHeight, aspect } = args
170
+ const rendered = renderedSourceRect({ wrapperW, wrapperH, srcWidth, srcHeight })
171
+ const px = fractionToWrapperPx({ crop: initialCrop, rendered })
172
+
173
+ const isW = handle === 'nw' || handle === 'sw'
174
+ const isN = handle === 'nw' || handle === 'ne'
175
+ // Opposite corner stays fixed.
176
+ const fixedX = isW ? px.x + px.w : px.x
177
+ const fixedY = isN ? px.y + px.h : px.y
178
+ // Dragged corner after the pointer delta.
179
+ const dragX = (isW ? px.x : px.x + px.w) + deltaPx.x
180
+
181
+ // Width is driven by the horizontal drag; height derived from the locked aspect.
182
+ const minW = MIN_FRACTION * rendered.width
183
+ let cw = Math.max(minW, Math.abs(dragX - fixedX))
184
+ let ch = cw / aspect
185
+
186
+ // Clamp the box to the rendered source bounds, preserving aspect (shrink to fit).
187
+ const dirX = isW ? -1 : 1
188
+ const dirY = isN ? -1 : 1
189
+ const maxW = dirX < 0 ? fixedX - rendered.offsetX : rendered.offsetX + rendered.width - fixedX
190
+ const maxH = dirY < 0 ? fixedY - rendered.offsetY : rendered.offsetY + rendered.height - fixedY
191
+ if (cw > maxW) { cw = maxW; ch = cw / aspect }
192
+ if (ch > maxH) { ch = maxH; cw = ch * aspect }
193
+
194
+ const left = dirX < 0 ? fixedX - cw : fixedX
195
+ const top = dirY < 0 ? fixedY - ch : fixedY
196
+ return wrapperPxToFraction({ px: { x: left, y: top, w: cw, h: ch }, rendered })
197
+ }
198
+
199
+ // Compute a centered crop with a fixed OUTPUT aspect (width/height in *pixels*,
200
+ // e.g. 9/16) at a given zoom. `zoom` >= 1: 1 is the largest crop with that aspect
201
+ // that fits the source; higher zooms shrink the crop (magnifying the output).
202
+ // `center` (source fractions) is preserved where possible, then clamped so the
203
+ // crop stays inside [0, 1]. Used by the crop modal's preset + zoom controls.
204
+ export function cropForAspect(args: {
205
+ aspect: number
206
+ zoom: number
207
+ srcWidth: number
208
+ srcHeight: number
209
+ center?: { x: number; y: number }
210
+ }): CropFraction {
211
+ const { aspect, srcWidth, srcHeight } = args
212
+ const srcAspect = srcWidth / srcHeight
213
+
214
+ // Largest crop fractions whose pixel aspect (w·srcW)/(h·srcH) equals `aspect`.
215
+ let w: number
216
+ let h: number
217
+ if (aspect >= srcAspect) {
218
+ // Crop is wider than the source → full source width, letterbox vertically.
219
+ w = 1
220
+ h = srcAspect / aspect
221
+ } else {
222
+ // Crop is taller than the source → full source height, pillarbox horizontally.
223
+ h = 1
224
+ w = aspect / srcAspect
225
+ }
226
+
227
+ const z = Math.max(1, args.zoom)
228
+ w = Math.min(1, w / z)
229
+ h = Math.min(1, h / z)
230
+
231
+ const cx = args.center?.x ?? 0.5
232
+ const cy = args.center?.y ?? 0.5
233
+ const x = Math.min(1 - w, Math.max(0, cx - w / 2))
234
+ const y = Math.min(1 - h, Math.max(0, cy - h / 2))
235
+ return { x, y, w, h }
236
+ }
237
+
238
+ // The smallest zoom (>= 1) that keeps a crop of the given aspect at or above the
239
+ // 2% min-fraction floor on both axes — i.e. the max useful zoom for the slider.
240
+ export function maxZoomForAspect(args: {
241
+ aspect: number
242
+ srcWidth: number
243
+ srcHeight: number
244
+ }): number {
245
+ const base = cropForAspect({ ...args, zoom: 1 })
246
+ // base.w / z >= MIN_FRACTION → z <= base.w / MIN_FRACTION (same for h)
247
+ const zByW = base.w / MIN_FRACTION
248
+ const zByH = base.h / MIN_FRACTION
249
+ return Math.max(1, Math.min(zByW, zByH))
250
+ }
package/src/types.ts CHANGED
@@ -503,6 +503,20 @@ export interface VideoEditorProps<P extends Project = Project> {
503
503
  */
504
504
  assetsPlacement?: 'sidebar' | 'right' | 'bottom'
505
505
 
506
+ /**
507
+ * Opt a host OUT of the package's built-in toolbar Render button so it can
508
+ * place Render in its own chrome (e.g. the desktop OS editor's top header).
509
+ * When provided, the package: (a) hides the toolbar Render button, and (b)
510
+ * calls this callback once with a stable `openRender()` trigger that marks the
511
+ * project final, saves, and opens the package's RenderModal. The host stores
512
+ * that trigger and wires it to its own Render button.
513
+ *
514
+ * Default (prop omitted): the package renders Render in its toolbar — so Hub
515
+ * clients, which deliberately keep no render button in their own headers, are
516
+ * unaffected.
517
+ */
518
+ onProvideRenderTrigger?: (openRender: () => void) => void
519
+
506
520
  // ── Host-supplied Montaj-specific UI (render-prop seams) ──────────────────
507
521
  // The clip/audio inspector and the subcut-regeneration tool read host-only
508
522
  // fields (regenQueue, storyboard, the host's full Project) the package types