// Crop editor for one take: drag a rectangle over a frame of the screen or camera track. import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; import type { Crop, Take, TakeCrop, TrackKind, Video } from '../../shared/types.ts'; import { isFullFrame } from '../../shared/types.ts'; import { computeGeometry } from '../../shared/layout.ts'; import { takeFileUrl } from '../api.ts'; import { fmtTimecode } from '../lib/format.ts'; import { Modal } from './Modal.tsx'; const FULL: Crop = { x: 0, y: 0, w: 1, h: 1 }; const MIN = 0.03; // smallest crop, as a fraction of the frame const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); type Aspect = 'free' | 'layout' | '16:9' | '4:3' | '1:1'; type Dims = { w: number; h: number }; // A drag either moves the rectangle or moves some of its edges while the opposite corner (ax, ay) stays put. type Drag = { kind: 'move'; px: number; py: number; start: Crop } | { kind: 'resize'; ax: number; ay: number; moveX: boolean; moveY: boolean; start: Crop }; const HANDLES = [ { id: 'nw', x: 0, y: 0 }, { id: 'n', x: 0.5, y: 0 }, { id: 'ne', x: 1, y: 0 }, { id: 'e', x: 1, y: 0.5 }, { id: 'se', x: 1, y: 1 }, { id: 's', x: 0.5, y: 1 }, { id: 'sw', x: 0, y: 1 }, { id: 'w', x: 0, y: 0.5 }, ] as const; // Keep a rectangle inside the frame and at least MIN in each dimension. function contain(c: Crop): Crop { const w = clamp(c.w, MIN, 1); const h = clamp(c.h, MIN, 1); return { x: clamp(c.x, 0, 1 - w), y: clamp(c.y, 0, 1 - h), w, h }; } // The largest rectangle with aspect `a` (width/height in frame fractions) inside `c`, centered in it. function fitAspect(c: Crop, a: number): Crop { const w = Math.min(c.w, c.h * a); const h = w / a; return contain({ x: c.x + (c.w - w) / 2, y: c.y + (c.h - h) / 2, w, h }); } export function CropDialog({ video, take, initialTime, onSave, onClose, }: { video: Video; take: Take; initialTime: number; onSave: (crop: TakeCrop, applyToAll: boolean) => void; onClose: () => void; }) { const tracks = (['screen', 'camera'] as const).filter((t) => take.tracks[t]); const [track, setTrack] = useState(tracks[0]); const [crops, setCrops] = useState>({ screen: video.crops?.[take.id]?.screen ?? FULL, camera: video.crops?.[take.id]?.camera ?? FULL, }); const [aspect, setAspect] = useState('free'); const [dims, setDims] = useState>>({}); const [time, setTime] = useState(initialTime); const [applyAll, setApplyAll] = useState(false); const frameRef = useRef(null); const drag = useRef(null); const crop = crops[track]; const d = dims[track]; const setCrop = (c: Crop) => setCrops((cs) => ({ ...cs, [track]: c })); // The aspect ratio of the box this track fills in the current layout (for takes with this take's tracks). const g = computeGeometry(video.layout, video.output, take.tracks.screen, take.tracks.camera); const layoutBox = track === 'screen' ? g.screen : g.camera?.box; const pixelAspect = (a: Aspect): number | null => a === 'free' ? null : a === 'layout' ? (layoutBox ? layoutBox.w / layoutBox.h : null) : a === '16:9' ? 16 / 9 : a === '4:3' ? 4 / 3 : 1; // Width/height ratio in frame fractions, which is what the rectangle is stored in. const fracAspect = (a: Aspect) => { const pa = pixelAspect(a); return pa && d ? (pa * d.h) / d.w : null; }; const chooseAspect = (a: Aspect) => { setAspect(a); const fa = fracAspect(a); if (fa) setCrop(fitAspect(crop, fa)); }; const pointAt = (e: { clientX: number; clientY: number }) => { const r = frameRef.current!.getBoundingClientRect(); return { x: clamp((e.clientX - r.left) / r.width, 0, 1), y: clamp((e.clientY - r.top) / r.height, 0, 1) }; }; const begin = (e: ReactPointerEvent, dr: Drag) => { e.preventDefault(); e.stopPropagation(); drag.current = dr; frameRef.current!.setPointerCapture(e.pointerId); }; const onFrameDown = (e: ReactPointerEvent) => { // Pressing outside the rectangle draws a new one from that point. const p = pointAt(e); begin(e, { kind: 'resize', ax: p.x, ay: p.y, moveX: true, moveY: true, start: { x: p.x, y: p.y, w: 0, h: 0 } }); }; const onMove = (e: ReactPointerEvent) => { const dr = drag.current; if (!dr) return; const p = pointAt(e); if (dr.kind === 'move') { const s = dr.start; setCrop({ ...s, x: clamp(s.x + p.x - dr.px, 0, 1 - s.w), y: clamp(s.y + p.y - dr.py, 0, 1 - s.h) }); return; } const s = dr.start; let x0 = dr.moveX ? Math.min(dr.ax, p.x) : s.x; let x1 = dr.moveX ? Math.max(dr.ax, p.x) : s.x + s.w; let y0 = dr.moveY ? Math.min(dr.ay, p.y) : s.y; let y1 = dr.moveY ? Math.max(dr.ay, p.y) : s.y + s.h; const fa = fracAspect(aspect); if (fa && dr.moveX && dr.moveY) { // Grow to the aspect ratio away from the anchor, then shrink if that leaves the frame. const sx = p.x >= dr.ax ? 1 : -1; const sy = p.y >= dr.ay ? 1 : -1; let w = Math.max(x1 - x0, (y1 - y0) * fa, MIN); w = Math.min(w, sx > 0 ? 1 - dr.ax : dr.ax, (sy > 0 ? 1 - dr.ay : dr.ay) * fa); const h = w / fa; x0 = sx > 0 ? dr.ax : dr.ax - w; y0 = sy > 0 ? dr.ay : dr.ay - h; x1 = x0 + w; y1 = y0 + h; } setCrop(contain({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 })); }; const onUp = () => { drag.current = null; }; const startHandle = (e: ReactPointerEvent, hx: number, hy: number) => { const c = crop; begin(e, { kind: 'resize', ax: hx === 0 ? c.x + c.w : c.x, ay: hy === 0 ? c.y + c.h : c.y, moveX: hx !== 0.5, moveY: hy !== 0.5, start: c, }); }; // The