/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / src / components / CropDialog.tsx
294 lines · 10.6 KBBlameHistoryRaw
1// Crop editor for one take: drag a rectangle over a frame of the screen or camera track.
3import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
4import type { Crop, Take, TakeCrop, TrackKind, Video } from '../../shared/types.ts';
5import { isFullFrame } from '../../shared/types.ts';
6import { computeGeometry } from '../../shared/layout.ts';
7import { takeFileUrl } from '../api.ts';
8import { fmtTimecode } from '../lib/format.ts';
9import { Modal } from './Modal.tsx';
11const FULL: Crop = { x: 0, y: 0, w: 1, h: 1 };
12const MIN = 0.03; // smallest crop, as a fraction of the frame
13const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
15type Aspect = 'free' | 'layout' | '16:9' | '4:3' | '1:1';
16type Dims = { w: number; h: number };
17// A drag either moves the rectangle or moves some of its edges while the opposite corner (ax, ay) stays put.
18type Drag = { kind: 'move'; px: number; py: number; start: Crop } | { kind: 'resize'; ax: number; ay: number; moveX: boolean; moveY: boolean; start: Crop };
20const HANDLES = [
21 { id: 'nw', x: 0, y: 0 },
22 { id: 'n', x: 0.5, y: 0 },
23 { id: 'ne', x: 1, y: 0 },
24 { id: 'e', x: 1, y: 0.5 },
25 { id: 'se', x: 1, y: 1 },
26 { id: 's', x: 0.5, y: 1 },
27 { id: 'sw', x: 0, y: 1 },
28 { id: 'w', x: 0, y: 0.5 },
29] as const;
31// Keep a rectangle inside the frame and at least MIN in each dimension.
32function contain(c: Crop): Crop {
33 const w = clamp(c.w, MIN, 1);
34 const h = clamp(c.h, MIN, 1);
35 return { x: clamp(c.x, 0, 1 - w), y: clamp(c.y, 0, 1 - h), w, h };
38// The largest rectangle with aspect `a` (width/height in frame fractions) inside `c`, centered in it.
39function fitAspect(c: Crop, a: number): Crop {
40 const w = Math.min(c.w, c.h * a);
41 const h = w / a;
42 return contain({ x: c.x + (c.w - w) / 2, y: c.y + (c.h - h) / 2, w, h });
45export function CropDialog({
46 video,
47 take,
48 initialTime,
49 onSave,
50 onClose,
51}: {
52 video: Video;
53 take: Take;
54 initialTime: number;
55 onSave: (crop: TakeCrop, applyToAll: boolean) => void;
56 onClose: () => void;
57}) {
58 const tracks = (['screen', 'camera'] as const).filter((t) => take.tracks[t]);
59 const [track, setTrack] = useState<TrackKind>(tracks[0]);
60 const [crops, setCrops] = useState<Record<TrackKind, Crop>>({
61 screen: video.crops?.[take.id]?.screen ?? FULL,
62 camera: video.crops?.[take.id]?.camera ?? FULL,
63 });
64 const [aspect, setAspect] = useState<Aspect>('free');
65 const [dims, setDims] = useState<Partial<Record<TrackKind, Dims>>>({});
66 const [time, setTime] = useState(initialTime);
67 const [applyAll, setApplyAll] = useState(false);
68 const frameRef = useRef<HTMLDivElement>(null);
69 const drag = useRef<Drag | null>(null);
71 const crop = crops[track];
72 const d = dims[track];
73 const setCrop = (c: Crop) => setCrops((cs) => ({ ...cs, [track]: c }));
75 // The aspect ratio of the box this track fills in the current layout (for takes with this take's tracks).
76 const g = computeGeometry(video.layout, video.output, take.tracks.screen, take.tracks.camera);
77 const layoutBox = track === 'screen' ? g.screen : g.camera?.box;
78 const pixelAspect = (a: Aspect): number | null =>
79 a === 'free' ? null : a === 'layout' ? (layoutBox ? layoutBox.w / layoutBox.h : null) : a === '16:9' ? 16 / 9 : a === '4:3' ? 4 / 3 : 1;
80 // Width/height ratio in frame fractions, which is what the rectangle is stored in.
81 const fracAspect = (a: Aspect) => {
82 const pa = pixelAspect(a);
83 return pa && d ? (pa * d.h) / d.w : null;
84 };
86 const chooseAspect = (a: Aspect) => {
87 setAspect(a);
88 const fa = fracAspect(a);
89 if (fa) setCrop(fitAspect(crop, fa));
90 };
92 const pointAt = (e: { clientX: number; clientY: number }) => {
93 const r = frameRef.current!.getBoundingClientRect();
94 return { x: clamp((e.clientX - r.left) / r.width, 0, 1), y: clamp((e.clientY - r.top) / r.height, 0, 1) };
95 };
97 const begin = (e: ReactPointerEvent, dr: Drag) => {
98 e.preventDefault();
99 e.stopPropagation();
100 drag.current = dr;
101 frameRef.current!.setPointerCapture(e.pointerId);
102 };
104 const onFrameDown = (e: ReactPointerEvent) => {
105 // Pressing outside the rectangle draws a new one from that point.
106 const p = pointAt(e);
107 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 } });
108 };
110 const onMove = (e: ReactPointerEvent) => {
111 const dr = drag.current;
112 if (!dr) return;
113 const p = pointAt(e);
114 if (dr.kind === 'move') {
115 const s = dr.start;
116 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) });
117 return;
118 }
119 const s = dr.start;
120 let x0 = dr.moveX ? Math.min(dr.ax, p.x) : s.x;
121 let x1 = dr.moveX ? Math.max(dr.ax, p.x) : s.x + s.w;
122 let y0 = dr.moveY ? Math.min(dr.ay, p.y) : s.y;
123 let y1 = dr.moveY ? Math.max(dr.ay, p.y) : s.y + s.h;
124 const fa = fracAspect(aspect);
125 if (fa && dr.moveX && dr.moveY) {
126 // Grow to the aspect ratio away from the anchor, then shrink if that leaves the frame.
127 const sx = p.x >= dr.ax ? 1 : -1;
128 const sy = p.y >= dr.ay ? 1 : -1;
129 let w = Math.max(x1 - x0, (y1 - y0) * fa, MIN);
130 w = Math.min(w, sx > 0 ? 1 - dr.ax : dr.ax, (sy > 0 ? 1 - dr.ay : dr.ay) * fa);
131 const h = w / fa;
132 x0 = sx > 0 ? dr.ax : dr.ax - w;
133 y0 = sy > 0 ? dr.ay : dr.ay - h;
134 x1 = x0 + w;
135 y1 = y0 + h;
136 }
137 setCrop(contain({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 }));
138 };
140 const onUp = () => {
141 drag.current = null;
142 };
144 const startHandle = (e: ReactPointerEvent, hx: number, hy: number) => {
145 const c = crop;
146 begin(e, {
147 kind: 'resize',
148 ax: hx === 0 ? c.x + c.w : c.x,
149 ay: hy === 0 ? c.y + c.h : c.y,
150 moveX: hx !== 0.5,
151 moveY: hy !== 0.5,
152 start: c,
153 });
154 };
156 // The <video> for each track seeks to the chosen time (it is kept paused).
157 const videoRef = useRef<HTMLVideoElement>(null);
158 useEffect(() => {
159 const el = videoRef.current;
160 if (el && Math.abs(el.currentTime - time) > 0.01) el.currentTime = time;
161 }, [time, track]);
163 const pct = (v: number) => `${v * 100}%`;
164 const locked = aspect !== 'free';
165 const pxW = d ? Math.round(crop.w * d.w) : null;
166 const pxH = d ? Math.round(crop.h * d.h) : null;
167 const frameWidth = d ? `min(100%, ${((58 * d.w) / d.h).toFixed(2)}vh)` : '100%';
169 const save = () => {
170 const out: TakeCrop = {};
171 for (const t of tracks) if (!isFullFrame(crops[t])) out[t] = crops[t];
172 onSave(out, applyAll);
173 onClose();
174 };
176 return (
177 <Modal
178 title={`Crop take ${video.takes.indexOf(take) + 1}`}
179 onClose={onClose}
180 className="crop-modal"
181 footer={
182 <>
183 <label className="check crop-apply-all">
184 <input type="checkbox" checked={applyAll} onChange={(e) => setApplyAll(e.target.checked)} />
185 Apply to all takes
186 </label>
187 <div className="spacer" />
188 <button className="btn" onClick={onClose}>
189 Cancel
190 </button>
191 <button className="btn primary" onClick={save}>
192 Save
193 </button>
194 </>
195 }
196 >
197 <div className="crop-toolbar">
198 {tracks.length > 1 && (
199 <div className="seg-control">
200 {tracks.map((t) => (
201 <button
202 key={t}
203 className={track === t ? 'active' : ''}
204 onClick={() => {
205 setTrack(t);
206 setAspect('free');
207 }}
208 >
209 {t === 'screen' ? 'Screen' : 'Camera'}
210 {!isFullFrame(crops[t]) && ' •'}
211 </button>
212 ))}
213 </div>
214 )}
215 <div className="seg-control">
216 {(
217 [
218 ['free', 'Free'],
219 ['layout', 'Fill layout'],
220 ['16:9', '16:9'],
221 ['4:3', '4:3'],
222 ['1:1', '1:1'],
223 ] as const
224 ).map(([a, label]) => (
225 <button
226 key={a}
227 className={aspect === a ? 'active' : ''}
228 disabled={a === 'layout' && !layoutBox}
229 title={a === 'layout' ? 'The shape of the area this track fills in the current layout, so that it is not letterboxed' : undefined}
230 onClick={() => chooseAspect(a)}
231 >
232 {label}
233 </button>
234 ))}
235 </div>
236 <div className="spacer" />
237 <button className="btn small" disabled={isFullFrame(crop)} onClick={() => setCrop(FULL)}>
238 Reset
239 </button>
240 </div>
242 <div className="crop-frame-wrap">
243 <div
244 className="crop-frame"
245 ref={frameRef}
246 style={{ width: frameWidth, aspectRatio: d ? `${d.w} / ${d.h}` : '16 / 9' }}
247 onPointerDown={onFrameDown}
248 onPointerMove={onMove}
249 onPointerUp={onUp}
250 onPointerCancel={onUp}
251 >
252 <video
253 key={track}
254 ref={videoRef}
255 src={takeFileUrl(video.id, take.id, track)}
256 muted
257 preload="auto"
258 playsInline
259 onLoadedMetadata={(e) => {
260 const el = e.currentTarget;
261 if (el.videoWidth && el.videoHeight) setDims((ds) => ({ ...ds, [track]: { w: el.videoWidth, h: el.videoHeight } }));
262 el.currentTime = time;
263 }}
264 />
265 <div className="crop-shade">
266 <div style={{ left: pct(crop.x), top: pct(crop.y), width: pct(crop.w), height: pct(crop.h) }} />
267 </div>
268 <div
269 className="crop-rect"
270 style={{ left: pct(crop.x), top: pct(crop.y), width: pct(crop.w), height: pct(crop.h) }}
271 onPointerDown={(e) => {
272 const p = pointAt(e);
273 begin(e, { kind: 'move', px: p.x, py: p.y, start: crop });
274 }}
275 >
276 <div className="crop-grid" />
277 {HANDLES.filter((h) => !locked || (h.x !== 0.5 && h.y !== 0.5)).map((h) => (
278 <div key={h.id} className={`crop-handle ${h.id}`} style={{ left: pct(h.x), top: pct(h.y) }} onPointerDown={(e) => startHandle(e, h.x, h.y)} />
279 ))}
280 </div>
281 </div>
282 </div>
284 <div className="crop-scrub">
285 <input type="range" min={0} max={take.duration} step={0.01} value={time} onChange={(e) => setTime(parseFloat(e.target.value))} aria-label="Frame" />
286 <span className="timecode small">{fmtTimecode(time)}</span>
287 </div>
288 <p className="muted small">
289 {pxW && pxH ? `${pxW} × ${pxH} px of ${d!.w} × ${d!.h}. ` : ''}
290 Drag inside the rectangle to move it, drag its handles to resize it, or drag elsewhere to draw a new one. The crop is fit into the layout like the full frame would be.
291 </p>
292 </Modal>
293 );
↑↓ move↵openescclose