Crop the screen and camera of each take; default output to 720p
A crop dialog on each take sets a rectangle, free or with a fixed aspect ratio, over the screen or camera track. Crops are stored per take as fractions of the frame and applied identically in the preview, the thumbnail, and the ffmpeg export. The default output resolution for new videos is now 720p.
13 changed files+646−60
README.mdmodified+5−2View file
@@ -41,6 +41,8 @@ The library shows every video with its thumbnail, duration, and description. Dra
4141
4242 **Layout and export.** For takes that have both camera and screen, the camera can be placed as picture-in-picture (corner, size, and rectangle, rounded, or circle shape), side by side, or hidden. The preview in the editor uses the same geometry as the export. Export renders the timeline to an H.264/AAC MP4 at 720p or 1080p, optionally with loudness normalization to −16 LUFS, and marks the export as outdated when the timeline or layout changes afterwards.
4343
44+**Cropping.** The *Crop* button on a take opens a frame of its screen or camera track with a rectangle that can be moved, resized, or redrawn by dragging; a slider picks the frame to show. This is useful for keeping only one window of a full-screen recording, or for reframing the camera. The rectangle can be free or locked to an aspect ratio, including *Fill layout*, the shape of the area that track occupies in the current layout, so that the cropped screen is not letterboxed. The crop is fit into the layout exactly as the full frame would be, and *Apply to all takes* copies it to the other takes. Like the timeline, cropping is non-destructive: it is stored as fractions of the frame in `video.json`, so it does not depend on the recorded resolution, and background removal does not need to run again when it changes.
45+
4446 **Background removal.** In *Layout & export*, the background behind you can be kept, blurred, replaced with a solid color, or removed entirely (*Cutout*, for picture-in-picture, where only you appear over the screen recording, sitting on the bottom edge of the video). Choosing any of these processes the camera track of each take with Robust Video Matting (Lin et al., 2021), a recurrent segmentation model designed for video, which gives soft edges around hair and little flicker between frames. The model runs on the CPU through onnxruntime; takes are processed one at a time in the background, and the result is stored as `cutout.webm` (VP9 with an alpha channel) so that each take is processed only once. The model (about 15 MB) is downloaded from GitHub to `~/.cache/takes/models/` the first time it is needed. Export waits until every take in the timeline has been processed.
4547
4648 **Background removal on another machine.** On a laptop CPU, background removal is slow, so it can instead run on a machine with an NVIDIA GPU that you reach over ssh. Set *ssh host* on the Settings page to an ssh destination such as `office` or `user@host`; it must accept key-based login without prompting, since Takes runs ssh without a terminal. That machine needs a working NVIDIA driver, Python 3.10 or later, and ffmpeg (with libvpx) on the `PATH`. If these come from environment modules or similar, put the commands that load them in *Setup command* (for example `module load python ffmpeg`); they run in a bash login shell before each job. For each take, Takes sends `camera.webm` over a single ssh connection, runs the same model with onnxruntime-gpu in a temporary directory there, receives `cutout.webm`, and removes the temporary directory. The first job creates a Python environment in `~/.cache/takes/venv` on the remote machine (about 2.5 GB, because pip installs the CUDA and cuDNN libraries); delete that directory to rebuild it. For a 60 s, 640×480 take, processing took 27.7 s on a remote RTX 6000 Ada, including the transfer, compared with 536 s on the CPU of a 12-thread laptop; the two cutouts differ by less than 1/255 in alpha on average.
@@ -52,7 +54,7 @@ The library shows every video with its thumbnail, duration, and description. Dra
5254 library.json order of videos
5355 config.json settings, including the Deepgram key (mode 0600)
5456 <video-id>/
55- video.json title, description, notes, takes, timeline, layout
57+ video.json title, description, notes, takes, timeline, layout, crops
5658 thumbnail.jpg
5759 <title>.mp4 latest export
5860 takes/<take-id>/
@@ -69,7 +71,8 @@ Everything is plain files, so videos can be backed up, synced, or inspected with
6971 - Screen and camera are recorded by two independent MediaRecorders started at the same instant. In our tests they stay aligned to within a frame, but we have not measured drift over recordings longer than about 20 minutes.
7072 - Screen capture on Wayland depends on the browser's PipeWire support. Recent Chrome enables it by default; if the picker does not appear or the capture is black, check `chrome://flags/#enable-webrtc-pipewire-capturer`.
7173 - Only the microphone is recorded; system audio (for example, sound from a demo) is not captured.
72-- The camera layout and background are set per video, not per segment.
74+- The camera layout and background are set per video, not per segment; crops are set per take.
75+- A crop is stored relative to the frame, so if a window recording changes size partway through, the crop follows the new frame rather than the same content.
7376 - Background removal on the CPU runs well below real time on a laptop (see above for running it on a remote GPU); the matting quality also degrades with a cluttered background, poor lighting, or objects held close to the body, which the model may partly treat as background.
7477 - Export speed depends on the CPU; with the `veryfast` x264 preset, a 1080p picture-in-picture export ran at roughly 3× real time on the development machine.
7578 - The server listens only on 127.0.0.1 and rejects requests addressed to other host names, but it has no authentication beyond that; it is meant for a single user on their own machine.
server/export.tsmodified+12−7View file
@@ -1,13 +1,13 @@
11 // Render the timeline to an MP4 with ffmpeg.
22 //
3-// Each segment becomes a sub-graph: a black canvas of the output size, the screen track fit
3+// Each segment becomes a sub-graph: a black canvas of the output size, the screen track (cropped, if set) fit
44 // into its box, the camera track (optionally masked to a rounded rect or circle) overlaid, and
55 // the mic audio trimmed/padded to exactly the segment length. The segments are then concatenated.
66
77 import fs from 'node:fs/promises';
88 import path from 'node:path';
9-import type { ExportJob, Video } from '../shared/types.ts';
10-import { exportSignature } from '../shared/types.ts';
9+import type { Crop, ExportJob, Video } from '../shared/types.ts';
10+import { cropOf, exportSignature } from '../shared/types.ts';
1111 import { FPS, computeGeometry } from '../shared/layout.ts';
1212 import { timelineDuration } from '../shared/timeline.ts';
1313 import { fileSize, run } from './media.ts';
@@ -19,6 +19,9 @@ export const getExportJob = (videoId: string) => jobs.get(videoId) ?? null;
1919
2020 const f3 = (x: number) => x.toFixed(3);
2121
22+// Crop filter for a rectangle given as fractions of the frame (ffmpeg rounds to the chroma grid), or nothing.
23+const cropFilter = (c: Crop | undefined) => (c ? `crop=w=iw*${c.w.toFixed(5)}:h=ih*${c.h.toFixed(5)}:x=iw*${c.x.toFixed(5)}:y=ih*${c.y.toFixed(5)},` : '');
24+
2225 // Alpha mask for a w x h box with corner radius r (r = w/2 on a square gives a circle), anti-aliased.
2326 function alphaExpr(r: number) {
2427 return `255*clip(${r}-hypot(max(abs(X+0.5-W/2)-(W/2-${r}),0),max(abs(Y+0.5-H/2)-(H/2-${r}),0))+0.5,0,1)`;
@@ -41,6 +44,8 @@ export function buildExportArgs(v: Video, outFile: string): string[] {
4144 const D = seg.end - seg.start;
4245 const dir = takeDir(v.id, take.id);
4346 const g = computeGeometry(v.layout, v.output, take.tracks.screen, take.tracks.camera);
47+ const screenCrop = cropFilter(cropOf(v, take.id, 'screen'));
48+ const cameraCrop = cropFilter(cropOf(v, take.id, 'camera'));
4449 const idx: Record<string, number> = {};
4550 const bgMode = g.camera?.background ?? 'none';
4651 if (bgMode !== 'none' && take.matte?.status !== 'done') {
@@ -57,7 +62,7 @@ export function buildExportArgs(v: Video, outFile: string): string[] {
5762 if (g.screen) {
5863 const b = g.screen;
5964 filters.push(
60- `[${idx.screen}:v]fps=${FPS}:start_time=0,scale=${b.w}:${b.h}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
65+ `[${idx.screen}:v]fps=${FPS}:start_time=0,${screenCrop}scale=${b.w}:${b.h}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
6166 `pad=${b.w}:${b.h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[s${i}]`,
6267 );
6368 filters.push(`[${cur}][s${i}]overlay=${b.x}:${b.y}:eof_action=repeat[os${i}]`);
@@ -70,10 +75,10 @@ export function buildExportArgs(v: Video, outFile: string): string[] {
7075 ? `scale=${b.w}:${b.h}:force_original_aspect_ratio=increase,crop=${b.w}:${b.h}`
7176 : `scale=${b.w}:${b.h}:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=${b.w}:${b.h}:(ow-iw)/2:(oh-ih)/2:color=${transparentPad ? 'black@0' : 'black'}`;
7277 if (background === 'none') {
73- filters.push(`[${idx.camera}:v]fps=${FPS}:start_time=0,${fitTo(false)},setsar=1[cam${i}]`);
78+ filters.push(`[${idx.camera}:v]fps=${FPS}:start_time=0,${cameraCrop}${fitTo(false)},setsar=1[cam${i}]`);
7479 } else {
75- filters.push(`[${idx.cutout}:v]fps=${FPS}:start_time=0,format=yuva420p,${fitTo(true)},setsar=1[fg${i}]`);
76- if (background === 'blur') filters.push(`[${idx.camera}:v]fps=${FPS}:start_time=0,${fitTo(false)},setsar=1,gblur=sigma=${blurSigma}[cb${i}]`);
80+ filters.push(`[${idx.cutout}:v]fps=${FPS}:start_time=0,format=yuva420p,${cameraCrop}${fitTo(true)},setsar=1[fg${i}]`);
81+ if (background === 'blur') filters.push(`[${idx.camera}:v]fps=${FPS}:start_time=0,${cameraCrop}${fitTo(false)},setsar=1,gblur=sigma=${blurSigma}[cb${i}]`);
7782 if (background === 'color') filters.push(`color=c=0x${color.replace('#', '')}:s=${b.w}x${b.h}:r=${FPS}:d=${f3(D)},setsar=1[cb${i}]`);
7883 if (background === 'cutout') filters.push(`[fg${i}]null[cam${i}]`);
7984 else filters.push(`[cb${i}][fg${i}]overlay=0:0:eof_action=repeat[cam${i}]`);
server/index.tsmodified+22−1View file
@@ -9,7 +9,8 @@ import fs from 'node:fs';
99 import os from 'node:os';
1010 import path from 'node:path';
1111 import { fileURLToPath } from 'node:url';
12-import type { PublicConfig, Video } from '../shared/types.ts';
12+import type { Crop, PublicConfig, TakeCrop, Video } from '../shared/types.ts';
13+import { isFullFrame } from '../shared/types.ts';
1314 import { getExportJob, startExport } from './export.ts';
1415 import { HttpError, checkId, createVideo, getConfig, getVideo, initStore, libraryDir, listVideos, setOrder, trashVideo, updateConfig, updateVideo, videoDir } from './store.ts';
1516 import { appendChunk, createTake, deleteTake, finalizeTake, setThumbnail } from './takes.ts';
@@ -119,6 +120,7 @@ app.patch('/api/videos/:id', json, async (req, res) => {
119120 }
120121 }
121122 if (b.output) v.output = { ...v.output, ...b.output };
123+ if (b.crops !== undefined) v.crops = parseCrops(b.crops, new Set(v.takes.map((t) => t.id)));
122124 if (Array.isArray(b.timeline)) {
123125 const takes = new Map(v.takes.map((t) => [t.id, t]));
124126 for (const s of b.timeline) {
@@ -131,6 +133,25 @@ app.patch('/api/videos/:id', json, async (req, res) => {
131133 res.json(v);
132134 });
133135
136+// Crops by take id, each track's rectangle inside the unit square; full-frame crops are dropped.
137+function parseCrops(raw: unknown, takeIds: Set<string>): Record<string, TakeCrop> {
138+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'Invalid crops');
139+ const out: Record<string, TakeCrop> = {};
140+ for (const [id, tc] of Object.entries(raw as Record<string, Record<string, Crop | undefined>>)) {
141+ if (!takeIds.has(id) || !tc || typeof tc !== 'object') throw new HttpError(400, 'Invalid crops');
142+ const kept: TakeCrop = {};
143+ for (const track of ['screen', 'camera'] as const) {
144+ const c = tc[track];
145+ if (!c) continue;
146+ const ok = [c.x, c.y, c.w, c.h].every((n) => typeof n === 'number' && Number.isFinite(n));
147+ if (!ok || c.x < 0 || c.y < 0 || c.w < 0.02 || c.h < 0.02 || c.x + c.w > 1.0001 || c.y + c.h > 1.0001) throw new HttpError(400, 'Invalid crop rectangle');
148+ if (!isFullFrame(c)) kept[track] = { x: c.x, y: c.y, w: Math.min(c.w, 1 - c.x), h: Math.min(c.h, 1 - c.y) };
149+ }
150+ if (kept.screen || kept.camera) out[id] = kept;
151+ }
152+ return out;
153+}
154+
134155 app.delete('/api/videos/:id', async (req, res) => {
135156 await trashVideo(p(req, 'id'));
136157 res.json({ ok: true });
server/store.tsmodified+1−1View file
@@ -84,7 +84,7 @@ const DEFAULT_CONFIG: AppConfig & { deepgramApiKey: string } = {
8484 autoTranscribe: true,
8585 defaultCountdown: 3,
8686 defaultCameraHeight: 720,
87- defaultOutputHeight: 1080,
87+ defaultOutputHeight: 720,
8888 matteHost: '',
8989 matteSetup: '',
9090 };
server/takes.tsmodified+1−0View file
@@ -141,6 +141,7 @@ export async function deleteTake(videoId: string, takeId: string) {
141141 if (!v.takes.some((t) => t.id === takeId)) throw new HttpError(404, 'Take not found');
142142 v.takes = v.takes.filter((t) => t.id !== takeId);
143143 v.timeline = v.timeline.filter((s) => s.takeId !== takeId);
144+ if (v.crops) delete v.crops[takeId];
144145 });
145146 await fs.rm(takeDir(videoId, takeId), { recursive: true, force: true });
146147 if (updated.thumbnail && !updated.thumbnail.custom) await autoThumbnail(videoId);
shared/layout.tsmodified+17−1View file
@@ -1,7 +1,7 @@
11 // Output geometry for a take, in output pixels. Used both by the ffmpeg export and by the
22 // browser preview, so that what you see while editing is what gets rendered.
33
4-import type { BackgroundMode, CameraShape, Layout, OutputSettings } from './types.ts';
4+import type { BackgroundMode, CameraShape, Crop, Layout, OutputSettings } from './types.ts';
55 import { backgroundOf } from './types.ts';
66
77 export interface Box {
@@ -84,3 +84,19 @@ export function computeGeometry(layout: Layout, output: OutputSettings, hasScree
8484 const radius = layout.shape === 'circle' ? cw / 2 : layout.shape === 'rounded' ? Math.round(Math.min(cw, ch) * 0.12) : 0;
8585 return { W, H, screen: full, camera: camera(box, 'cover', layout.shape, radius) };
8686 }
87+
88+// Where a cropped source frame of vw x vh pixels lands when fit into a box. `clip` is the visible
89+// region, relative to the box; `media` is the whole (uncropped) frame, relative to `clip`. For
90+// 'cover' the cropped region is scaled to fill the box and centered, as in the export.
91+export function placeCropped(box: Box, vw: number, vh: number, crop: Crop | undefined, fit: 'cover' | 'contain'): { clip: Box; media: Box } {
92+ const c = crop ?? { x: 0, y: 0, w: 1, h: 1 };
93+ const cw = c.w * vw;
94+ const ch = c.h * vh;
95+ const s = fit === 'contain' ? Math.min(box.w / cw, box.h / ch) : Math.max(box.w / cw, box.h / ch);
96+ const w = cw * s;
97+ const h = ch * s;
98+ const clip = fit === 'contain' ? { x: (box.w - w) / 2, y: (box.h - h) / 2, w, h } : { x: 0, y: 0, w: box.w, h: box.h };
99+ const ox = fit === 'contain' ? 0 : (box.w - w) / 2;
100+ const oy = fit === 'contain' ? 0 : (box.h - h) / 2;
101+ return { clip, media: { x: ox - c.x * vw * s, y: oy - c.y * vh * s, w: vw * s, h: vh * s } };
102+}
shared/types.tsmodified+31−2View file
@@ -51,6 +51,21 @@ export interface Layout {
5151 background?: Background; // absent in videos created before background removal existed
5252 }
5353
54+// A crop rectangle as fractions of the source frame, so it does not depend on the recorded resolution.
55+export interface Crop {
56+ x: number;
57+ y: number;
58+ w: number;
59+ h: number;
60+}
61+
62+// Per-take crops of the raw tracks; a missing track means the full frame.
63+export type TakeCrop = Partial<Record<TrackKind, Crop>>;
64+
65+export const cropOf = (v: Pick<Video, 'crops'>, takeId: string, track: TrackKind): Crop | undefined => v.crops?.[takeId]?.[track];
66+
67+export const isFullFrame = (c: Crop) => c.x < 0.001 && c.y < 0.001 && c.w > 0.999 && c.h > 0.999;
68+
5469 export interface OutputSettings {
5570 height: 720 | 1080;
5671 normalizeAudio: boolean;
@@ -74,6 +89,7 @@ export interface Video {
7489 takes: Take[];
7590 timeline: Segment[];
7691 layout: Layout;
92+ crops?: Record<string, TakeCrop>; // by take id; absent in videos created before cropping existed
7793 output: OutputSettings;
7894 thumbnail: { file: string; custom: boolean; updatedAt: string } | null;
7995 export: ExportInfo | null;
@@ -136,14 +152,27 @@ export interface Peaks {
136152 data: string;
137153 }
138154
139-export function exportSignature(v: Pick<Video, 'timeline' | 'layout' | 'output'>): string {
155+export function exportSignature(v: Pick<Video, 'timeline' | 'layout' | 'output' | 'crops'>): string {
140156 const seg = v.timeline.map((s) => [s.takeId, round3(s.start), round3(s.end)]);
141157 const l = v.layout;
142158 const o = v.output;
143159 const b = backgroundOf(l);
144160 // The background is omitted when off so that exports made before it existed stay current.
145161 const bg = b.mode === 'color' ? ['color', b.color] : b.mode === 'blur' ? ['blur', b.blur] : b.mode;
146- return JSON.stringify({ seg, layout: [l.mode, l.corner, round3(l.size), l.shape], ...(b.mode === 'none' ? {} : { bg }), output: [o.height, o.normalizeAudio] });
162+ // Likewise, crops are included only for takes in the timeline that have one.
163+ const crop: Record<string, (number[] | null)[]> = {};
164+ for (const id of new Set(v.timeline.map((s) => s.takeId))) {
165+ const c = v.crops?.[id];
166+ const r = (x?: Crop) => (x ? [round3(x.x), round3(x.y), round3(x.w), round3(x.h)] : null);
167+ if (c?.screen || c?.camera) crop[id] = [r(c.screen), r(c.camera)];
168+ }
169+ return JSON.stringify({
170+ seg,
171+ layout: [l.mode, l.corner, round3(l.size), l.shape],
172+ ...(b.mode === 'none' ? {} : { bg }),
173+ ...(Object.keys(crop).length ? { crop } : {}),
174+ output: [o.height, o.normalizeAudio],
175+ });
147176 }
148177
149178 function round3(x: number) {
src/components/CropDialog.tsxadded+294−0View file
@@ -0,0 +1,294 @@
1+// Crop editor for one take: drag a rectangle over a frame of the screen or camera track.
2+
3+import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
4+import type { Crop, Take, TakeCrop, TrackKind, Video } from '../../shared/types.ts';
5+import { isFullFrame } from '../../shared/types.ts';
6+import { computeGeometry } from '../../shared/layout.ts';
7+import { takeFileUrl } from '../api.ts';
8+import { fmtTimecode } from '../lib/format.ts';
9+import { Modal } from './Modal.tsx';
10+
11+const FULL: Crop = { x: 0, y: 0, w: 1, h: 1 };
12+const MIN = 0.03; // smallest crop, as a fraction of the frame
13+const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
14+
15+type Aspect = 'free' | 'layout' | '16:9' | '4:3' | '1:1';
16+type 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.
18+type Drag = { kind: 'move'; px: number; py: number; start: Crop } | { kind: 'resize'; ax: number; ay: number; moveX: boolean; moveY: boolean; start: Crop };
19+
20+const 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;
30+
31+// Keep a rectangle inside the frame and at least MIN in each dimension.
32+function 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 };
36+}
37+
38+// The largest rectangle with aspect `a` (width/height in frame fractions) inside `c`, centered in it.
39+function 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 });
43+}
44+
45+export 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);
70+
71+ const crop = crops[track];
72+ const d = dims[track];
73+ const setCrop = (c: Crop) => setCrops((cs) => ({ ...cs, [track]: c }));
74+
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+ };
85+
86+ const chooseAspect = (a: Aspect) => {
87+ setAspect(a);
88+ const fa = fracAspect(a);
89+ if (fa) setCrop(fitAspect(crop, fa));
90+ };
91+
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+ };
96+
97+ const begin = (e: ReactPointerEvent, dr: Drag) => {
98+ e.preventDefault();
99+ e.stopPropagation();
100+ drag.current = dr;
101+ frameRef.current!.setPointerCapture(e.pointerId);
102+ };
103+
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+ };
109+
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+ };
139+
140+ const onUp = () => {
141+ drag.current = null;
142+ };
143+
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+ };
155+
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]);
162+
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%';
168+
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+ };
175+
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>
241+
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>
283+
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+ );
294+}
src/components/Icon.tsxmodified+1−0View file
@@ -29,6 +29,7 @@ const PATHS: Record<string, string> = {
2929 external: 'M14 4h6v6M20 4l-9 9M19 14v5H5V5h5',
3030 trimStart: 'M5 4v16M9 12h10M13 8l-4 4 4 4',
3131 trimEnd: 'M19 4v16M15 12H5M11 8l4 4-4 4',
32+ crop: 'M6 2v14a2 2 0 0 0 2 2h14M18 22V8a2 2 0 0 0-2-2H2',
3233 };
3334
3435 export function Icon({ name, size = 16, className }: { name: keyof typeof PATHS | string; size?: number; className?: string }) {
src/components/Modal.tsxmodified+16−2View file
@@ -1,6 +1,20 @@
11 import { useEffect, type ReactNode } from 'react';
22
3-export function Modal({ title, onClose, children, footer, wide }: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; wide?: boolean }) {
3+export function Modal({
4+ title,
5+ onClose,
6+ children,
7+ footer,
8+ wide,
9+ className,
10+}: {
11+ title: string;
12+ onClose: () => void;
13+ children: ReactNode;
14+ footer?: ReactNode;
15+ wide?: boolean;
16+ className?: string;
17+}) {
418 useEffect(() => {
519 const onKey = (e: KeyboardEvent) => {
620 if (e.key === 'Escape') onClose();
@@ -10,7 +24,7 @@ export function Modal({ title, onClose, children, footer, wide }: { title: strin
1024 }, [onClose]);
1125 return (
1226 <div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
13- <div className={`modal${wide ? ' wide' : ''}`} role="dialog" aria-modal="true" aria-label={title}>
27+ <div className={`modal${wide ? ' wide' : ''}${className ? ` ${className}` : ''}`} role="dialog" aria-modal="true" aria-label={title}>
1428 <h2>{title}</h2>
1529 <div className="modal-body">{children}</div>
1630 {footer && <div className="modal-footer">{footer}</div>}
src/components/Stage.tsxmodified+94−40View file
@@ -1,13 +1,42 @@
11 // Composite preview of the timeline, laid out in output pixels and scaled to fit, so it matches the export.
22
3-import { useEffect, useRef, useState, type CSSProperties } from 'react';
4-import type { Take, Video } from '../../shared/types.ts';
5-import { computeGeometry, outputSize, type Box, type Camera } from '../../shared/layout.ts';
3+import { useEffect, useRef, useState, type CSSProperties, type SyntheticEvent } from 'react';
4+import type { Crop, Take, Video } from '../../shared/types.ts';
5+import { cropOf } from '../../shared/types.ts';
6+import { computeGeometry, outputSize, placeCropped, type Box, type Camera } from '../../shared/layout.ts';
67 import { takeFileUrl } from '../api.ts';
78 import { usePlayerActiveTake, type TimelinePlayer } from '../lib/player.ts';
89
910 const boxStyle = (b: Box): CSSProperties => ({ left: b.x, top: b.y, width: b.w, height: b.h });
1011
12+type Dims = { w: number; h: number } | null;
13+
14+// The intrinsic size of a <video>, which the crop placement needs; it can change mid-stream.
15+function useVideoDims(): [Dims, (e: SyntheticEvent<HTMLVideoElement>) => void] {
16+ const [dims, setDims] = useState<Dims>(null);
17+ const onDims = (e: SyntheticEvent<HTMLVideoElement>) => {
18+ const { videoWidth: w, videoHeight: h } = e.currentTarget;
19+ if (w && h) setDims((d) => (d && d.w === w && d.h === h ? d : { w, h }));
20+ };
21+ return [dims, onDims];
22+}
23+
24+// Styles for a (possibly cropped) frame fit into `box`: an overflow-hidden clip region, and the
25+// media inside it. Uncropped media simply use object-fit; cropped media stay hidden until their size is known.
26+function cropStyles(box: Box, dims: Dims, crop: Crop | undefined, fit: 'cover' | 'contain'): { clip: CSSProperties; media: CSSProperties } {
27+ const clipBase: CSSProperties = { position: 'absolute', overflow: 'hidden' };
28+ if (!crop || !dims) {
29+ return {
30+ clip: { ...clipBase, inset: 0 },
31+ media: { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: fit, ...(crop ? { visibility: 'hidden' } : {}) },
32+ };
33+ }
34+ const { clip, media } = placeCropped(box, dims.w, dims.h, crop, fit);
35+ // Transforms (the scale behind a blur) are centered on the visible region, not on the whole frame.
36+ const origin = `${clip.w / 2 - media.x}px ${clip.h / 2 - media.y}px`;
37+ return { clip: { ...clipBase, ...boxStyle(clip) }, media: { position: 'absolute', ...boxStyle(media), maxWidth: 'none', transformOrigin: origin } };
38+}
39+
1140 export function Stage({ video, player }: { video: Video; player: TimelinePlayer }) {
1241 const active = usePlayerActiveTake(player);
1342 const outerRef = useRef<HTMLDivElement>(null);
@@ -30,18 +59,8 @@ export function Stage({ video, player }: { video: Video; player: TimelinePlayer
3059 const g = computeGeometry(video.layout, video.output, t.tracks.screen, t.tracks.camera);
3160 return (
3261 <div key={t.id} style={{ display: t.id === active ? 'block' : 'none' }}>
33- {t.tracks.screen && (
34- <video
35- ref={player.refFor(t.id, 'screen')}
36- src={takeFileUrl(video.id, t.id, 'screen')}
37- muted={t.audio !== 'screen'}
38- preload="auto"
39- playsInline
40- className="layer"
41- style={g.screen ? { ...boxStyle(g.screen), objectFit: 'contain' } : { display: 'none' }}
42- />
43- )}
44- {t.tracks.camera && <CameraLayer videoId={video.id} take={t} cam={g.camera} player={player} />}
62+ {t.tracks.screen && <ScreenLayer videoId={video.id} take={t} box={g.screen} crop={cropOf(video, t.id, 'screen')} player={player} />}
63+ {t.tracks.camera && <CameraLayer videoId={video.id} take={t} cam={g.camera} crop={cropOf(video, t.id, 'camera')} player={player} />}
4564 </div>
4665 );
4766 })}
@@ -51,49 +70,82 @@ export function Stage({ video, player }: { video: Video; player: TimelinePlayer
5170 );
5271 }
5372
73+// The screen track fit (letterboxed) into its box. The element stays mounted even when hidden, since it may carry the audio.
74+function ScreenLayer({ videoId, take, box, crop, player }: { videoId: string; take: Take; box: Box | null; crop: Crop | undefined; player: TimelinePlayer }) {
75+ const [dims, onDims] = useVideoDims();
76+ const props = {
77+ ref: player.refFor(take.id, 'screen'),
78+ src: takeFileUrl(videoId, take.id, 'screen'),
79+ muted: take.audio !== 'screen',
80+ preload: 'auto',
81+ playsInline: true,
82+ onLoadedMetadata: onDims,
83+ onResize: onDims,
84+ } as const;
85+ if (!box) return <video {...props} style={{ display: 'none' }} />;
86+ const st = cropStyles(box, dims, crop, 'contain');
87+ return (
88+ <div className="layer" style={{ ...boxStyle(box), background: '#000' }}>
89+ <div style={st.clip}>
90+ <video {...props} style={st.media} />
91+ </div>
92+ </div>
93+ );
94+}
95+
5496 // The camera box: the original camera, or (once background removal has run) the chosen background
5597 // with the cutout on top. The camera element stays mounted even when hidden, since it carries the audio.
56-function CameraLayer({ videoId, take, cam, player }: { videoId: string; take: Take; cam: Camera | null; player: TimelinePlayer }) {
98+function CameraLayer({ videoId, take, cam, crop, player }: { videoId: string; take: Take; cam: Camera | null; crop: Crop | undefined; player: TimelinePlayer }) {
99+ const [dims, onDims] = useVideoDims();
57100 const camSrc = takeFileUrl(videoId, take.id, 'camera');
58101 const muted = take.audio !== 'camera';
59102 if (!cam) return <video ref={player.refFor(take.id, 'camera')} src={camSrc} muted={muted} preload="auto" playsInline style={{ display: 'none' }} />;
60103 const hasCutout = cam.background !== 'none' && take.matte?.status === 'done';
61104 const bg = hasCutout ? cam.background : 'none';
62- const media: CSSProperties = { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: cam.fit };
105+ // The cutout has the camera's aspect ratio, so the same placement applies to both.
106+ const { clip, media } = cropStyles(cam.box, dims, crop, cam.fit);
63107 return (
64108 <div className="layer" style={{ ...boxStyle(cam.box), borderRadius: cam.radius, overflow: 'hidden', background: bg === 'color' ? cam.color : bg === 'cutout' ? 'transparent' : '#000' }}>
65- <video
66- ref={player.refFor(take.id, 'camera')}
67- src={camSrc}
68- muted={muted}
69- preload="auto"
70- playsInline
71- style={{
72- ...media,
73- visibility: bg === 'color' || bg === 'cutout' ? 'hidden' : 'visible',
74- // Scaled slightly so the blur does not fade out at the edges of the box.
75- ...(bg === 'blur' ? { filter: `blur(${cam.blurSigma}px)`, transform: 'scale(1.06)' } : {}),
76- }}
77- />
78- {hasCutout && <video ref={player.refFor(take.id, 'cutout')} src={takeFileUrl(videoId, take.id, 'cutout', take.matte!.updatedAt)} muted preload="auto" playsInline style={media} />}
109+ <div style={clip}>
110+ <video
111+ ref={player.refFor(take.id, 'camera')}
112+ src={camSrc}
113+ muted={muted}
114+ preload="auto"
115+ playsInline
116+ onLoadedMetadata={onDims}
117+ onResize={onDims}
118+ style={{
119+ ...media,
120+ visibility: bg === 'color' || bg === 'cutout' ? 'hidden' : media.visibility,
121+ // Scaled slightly so the blur does not fade out at the edges of the box.
122+ ...(bg === 'blur' ? { filter: `blur(${cam.blurSigma}px)`, transform: 'scale(1.06)' } : {}),
123+ }}
124+ />
125+ {hasCutout && <video ref={player.refFor(take.id, 'cutout')} src={takeFileUrl(videoId, take.id, 'cutout', take.matte!.updatedAt)} muted preload="auto" playsInline style={media} />}
126+ </div>
79127 </div>
80128 );
81129 }
82130
83-function drawFit(ctx: CanvasRenderingContext2D, el: HTMLVideoElement, b: Box, fit: 'contain' | 'cover') {
84- const vw = el.videoWidth;
85- const vh = el.videoHeight;
131+// Draw the (cropped) frame of `el` fit into box `b`; the crop is in fractions of the frame.
132+function drawFit(ctx: CanvasRenderingContext2D, el: HTMLVideoElement, b: Box, fit: 'contain' | 'cover', crop?: Crop) {
133+ const c = crop ?? { x: 0, y: 0, w: 1, h: 1 };
134+ const cx = c.x * el.videoWidth;
135+ const cy = c.y * el.videoHeight;
136+ const vw = c.w * el.videoWidth;
137+ const vh = c.h * el.videoHeight;
86138 if (!vw || !vh) return;
87139 if (fit === 'contain') {
88140 const s = Math.min(b.w / vw, b.h / vh);
89141 const w = vw * s;
90142 const h = vh * s;
91- ctx.drawImage(el, b.x + (b.w - w) / 2, b.y + (b.h - h) / 2, w, h);
143+ ctx.drawImage(el, cx, cy, vw, vh, b.x + (b.w - w) / 2, b.y + (b.h - h) / 2, w, h);
92144 } else {
93145 const s = Math.max(b.w / vw, b.h / vh);
94146 const sw = b.w / s;
95147 const sh = b.h / s;
96- ctx.drawImage(el, (vw - sw) / 2, (vh - sh) / 2, sw, sh, b.x, b.y, b.w, b.h);
148+ ctx.drawImage(el, cx + (vw - sw) / 2, cy + (vh - sh) / 2, sw, sh, b.x, b.y, b.w, b.h);
97149 }
98150 }
99151
@@ -111,7 +163,9 @@ export async function captureFrame(video: Video, player: TimelinePlayer): Promis
111163 ctx.scale(640 / g.W, 640 / g.W);
112164 ctx.fillStyle = '#000';
113165 ctx.fillRect(0, 0, g.W, g.H);
114- if (g.screen && els.screen) drawFit(ctx, els.screen, g.screen, 'contain');
166+ const screenCrop = cropOf(video, take.id, 'screen');
167+ const cameraCrop = cropOf(video, take.id, 'camera');
168+ if (g.screen && els.screen) drawFit(ctx, els.screen, g.screen, 'contain', screenCrop);
115169 if (g.camera && els.camera) {
116170 const { box, fit, radius, background, color, blurSigma } = g.camera;
117171 const cutout = background !== 'none' && take.matte?.status === 'done' ? els.cutout : undefined;
@@ -119,17 +173,17 @@ export async function captureFrame(video: Video, player: TimelinePlayer): Promis
119173 ctx.beginPath();
120174 ctx.roundRect(box.x, box.y, box.w, box.h, radius);
121175 ctx.clip();
122- if (!cutout) drawFit(ctx, els.camera, box, fit);
176+ if (!cutout) drawFit(ctx, els.camera, box, fit, cameraCrop);
123177 else {
124178 if (background === 'color') {
125179 ctx.fillStyle = color;
126180 ctx.fillRect(box.x, box.y, box.w, box.h);
127181 } else if (background === 'blur') {
128182 ctx.filter = `blur(${(blurSigma * 640) / g.W}px)`;
129- drawFit(ctx, els.camera, box, fit);
183+ drawFit(ctx, els.camera, box, fit, cameraCrop);
130184 ctx.filter = 'none';
131185 }
132- drawFit(ctx, cutout, box, fit);
186+ drawFit(ctx, cutout, box, fit, cameraCrop);
133187 }
134188 ctx.restore();
135189 }
src/pages/VideoPage.tsxmodified+62−4View file
@@ -1,11 +1,12 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2-import type { Background, ExportJob, Layout, OutputSettings, PublicConfig, Segment, Take, Transcript, Video } from '../../shared/types.ts';
2+import type { Background, ExportJob, Layout, OutputSettings, PublicConfig, Segment, Take, TakeCrop, Transcript, Video } from '../../shared/types.ts';
33 import { backgroundOf, exportSignature } from '../../shared/types.ts';
44 import { computeGeometry } from '../../shared/layout.ts';
55 import { cutRange, locate, moveSegment, randomId, segDuration, segmentStarts, splitAt, timelineDuration } from '../../shared/timeline.ts';
66 import { navigate } from '../App.tsx';
77 import { api } from '../api.ts';
88 import { Icon } from '../components/Icon.tsx';
9+import { CropDialog } from '../components/CropDialog.tsx';
910 import { ConfirmDialog, type ConfirmRequest } from '../components/Modal.tsx';
1011 import { Stage, captureFrame } from '../components/Stage.tsx';
1112 import { Timeline, takeHue, type PeaksData } from '../components/Timeline.tsx';
@@ -39,6 +40,7 @@ export function VideoPage({ videoId }: { videoId: string }) {
3940 const [transcripts, setTranscripts] = useState<Record<string, Transcript>>({});
4041 const [job, setJob] = useState<ExportJob | null>(null);
4142 const [confirm, setConfirm] = useState<ConfirmRequest | null>(null);
43+ const [cropping, setCropping] = useState<{ take: Take; time: number } | null>(null);
4244 const [zoom, setZoom] = useState(1);
4345 const [saveState, setSaveState] = useState<'saved' | 'saving' | 'error'>('saved');
4446 const player = useMemo(() => new TimelinePlayer(), []);
@@ -240,7 +242,7 @@ export function VideoPage({ videoId }: { videoId: string }) {
240242 const keyHandler = useRef<(e: KeyboardEvent) => void>(() => {});
241243 keyHandler.current = (e: KeyboardEvent) => {
242244 const el = e.target as HTMLElement;
243- if (el.closest('input, textarea, select, [contenteditable]') || confirm) return;
245+ if (el.closest('input, textarea, select, [contenteditable]') || confirm || cropping) return;
244246 const mod = e.ctrlKey || e.metaKey;
245247 const k = e.key;
246248 const step = e.shiftKey ? 5 : 1;
@@ -333,6 +335,27 @@ export function VideoPage({ videoId }: { videoId: string }) {
333335 setError((e as Error).message);
334336 }
335337 };
338+ const openCrop = (t: Take) => {
339+ // Show the frame under the playhead if it is in this take.
340+ const loc = locate(tl, now());
341+ setCropping({ take: t, time: loc && tl[loc.index].takeId === t.id ? loc.takeTime : 0 });
342+ };
343+ // Replace this take's crop; with applyToAll, also give each track's crop to every other take that has that track.
344+ const saveCrop = (take: Take, crop: TakeCrop, applyToAll: boolean) => {
345+ const crops = { ...video.crops };
346+ const targets = applyToAll ? readyTakes : [take];
347+ for (const t of targets) {
348+ const next: TakeCrop = { ...crops[t.id] };
349+ for (const track of ['screen', 'camera'] as const) {
350+ if (!take.tracks[track] || !t.tracks[track]) continue;
351+ if (crop[track]) next[track] = crop[track];
352+ else delete next[track];
353+ }
354+ if (next.screen || next.camera) crops[t.id] = next;
355+ else delete crops[t.id];
356+ }
357+ patch({ crops }, 200);
358+ };
336359 const cameraTakes = readyTakes.filter((t) => t.tracks.camera);
337360 // Takes in the timeline whose camera appears in the output but has not had its background removed yet.
338361 const matteBlocking =
@@ -449,7 +472,9 @@ export function VideoPage({ videoId }: { videoId: string }) {
449472 take={t}
450473 n={takeNumber(t.id)}
451474 inTimeline={video.timeline.some((s) => s.takeId === t.id)}
475+ cropped={!!(video.crops?.[t.id]?.screen || video.crops?.[t.id]?.camera)}
452476 onAdd={() => addToTimeline(t)}
477+ onCrop={() => openCrop(t)}
453478 onDelete={() => askDeleteTake(t)}
454479 onRecover={() => replaceFromServer(() => api.finalizeTake(videoId, t.id))}
455480 />
@@ -672,7 +697,7 @@ export function VideoPage({ videoId }: { videoId: string }) {
672697 {fmtDuration(video.export.duration)} · {fmtBytes(video.export.bytes)} · {fmtDateTime(video.export.exportedAt)}
673698 </span>
674699 </div>
675- {stale && <p className="small warn-text">The timeline or layout changed since this export.</p>}
700+ {stale && <p className="small warn-text">The timeline, layout, or crop changed since this export.</p>}
676701 <button className="btn small" onClick={() => api.openFolder(videoId).catch((e) => setError(e.message))}>
677702 <Icon name="folder" /> Show in folder
678703 </button>
@@ -685,6 +710,15 @@ export function VideoPage({ videoId }: { videoId: string }) {
685710 </div>
686711
687712 {confirm && <ConfirmDialog req={confirm} onClose={() => setConfirm(null)} />}
713+ {cropping && (
714+ <CropDialog
715+ video={video}
716+ take={cropping.take}
717+ initialTime={cropping.time}
718+ onSave={(crop, all) => saveCrop(cropping.take, crop, all)}
719+ onClose={() => setCropping(null)}
720+ />
721+ )}
688722 </div>
689723 );
690724 }
@@ -733,7 +767,25 @@ function TimeDisplay({ player, duration }: { player: TimelinePlayer; duration: n
733767 );
734768 }
735769
736-function TakeCard({ take, n, inTimeline, onAdd, onDelete, onRecover }: { take: Take; n: number; inTimeline: boolean; onAdd: () => void; onDelete: () => void; onRecover: () => void }) {
770+function TakeCard({
771+ take,
772+ n,
773+ inTimeline,
774+ cropped,
775+ onAdd,
776+ onCrop,
777+ onDelete,
778+ onRecover,
779+}: {
780+ take: Take;
781+ n: number;
782+ inTimeline: boolean;
783+ cropped: boolean;
784+ onAdd: () => void;
785+ onCrop: () => void;
786+ onDelete: () => void;
787+ onRecover: () => void;
788+}) {
737789 return (
738790 <div className="take" style={{ ['--hue' as string]: takeHue(n) }}>
739791 <div className="take-head">
@@ -750,6 +802,7 @@ function TakeCard({ take, n, inTimeline, onAdd, onDelete, onRecover }: { take: T
750802 {take.status === 'ready' && ` · ${fmtDuration(take.duration)}`}
751803 {take.transcript?.status === 'done' && ' · transcribed'}
752804 {take.transcript?.status === 'pending' && ' · transcribing…'}
805+ {cropped && ' · cropped'}
753806 {take.matte?.status === 'done' && ' · background removed'}
754807 {(take.matte?.status === 'queued' || take.matte?.status === 'running') && ' · removing background…'}
755808 </div>
@@ -763,6 +816,11 @@ function TakeCard({ take, n, inTimeline, onAdd, onDelete, onRecover }: { take: T
763816 <Icon name="plus" /> {inTimeline ? 'Add again' : 'Add to timeline'}
764817 </button>
765818 )}
819+ {take.status === 'ready' && (take.tracks.screen || take.tracks.camera) && (
820+ <button className="btn small" onClick={onCrop} title="Crop the screen or camera of this take">
821+ <Icon name="crop" /> Crop
822+ </button>
823+ )}
766824 {(take.status === 'recording' || take.status === 'failed') && (
767825 <button className="btn small" onClick={onRecover} title="Try to finalize whatever was recorded">
768826 Recover
src/styles.cssmodified+90−0View file
@@ -357,6 +357,96 @@ input[type='checkbox'] {
357357 margin-top: 18px;
358358 }
359359
360+/* ---- crop dialog ---- */
361+.modal.crop-modal {
362+ width: min(1000px, calc(100vw - 32px));
363+}
364+.crop-toolbar {
365+ display: flex;
366+ flex-wrap: wrap;
367+ align-items: center;
368+ gap: 10px;
369+}
370+.crop-frame-wrap {
371+ display: flex;
372+ justify-content: center;
373+ padding: 8px;
374+ background: #000;
375+ border-radius: var(--radius);
376+}
377+.crop-frame {
378+ position: relative;
379+ max-width: 100%;
380+ touch-action: none;
381+ cursor: crosshair;
382+ user-select: none;
383+}
384+.crop-frame video {
385+ display: block;
386+ width: 100%;
387+ height: 100%;
388+ pointer-events: none;
389+}
390+.crop-shade {
391+ position: absolute;
392+ inset: 0;
393+ overflow: hidden;
394+ pointer-events: none;
395+}
396+.crop-shade div {
397+ position: absolute;
398+ box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.6);
399+}
400+.crop-rect {
401+ position: absolute;
402+ outline: 1px solid rgba(255, 255, 255, 0.9);
403+ cursor: move;
404+}
405+.crop-grid {
406+ position: absolute;
407+ inset: 0;
408+ pointer-events: none;
409+ background:
410+ linear-gradient(to right, transparent calc(33.33% - 0.5px), rgba(255, 255, 255, 0.35) calc(33.33% - 0.5px), rgba(255, 255, 255, 0.35) calc(33.33% + 0.5px), transparent calc(33.33% + 0.5px), transparent calc(66.67% - 0.5px), rgba(255, 255, 255, 0.35) calc(66.67% - 0.5px), rgba(255, 255, 255, 0.35) calc(66.67% + 0.5px), transparent calc(66.67% + 0.5px)),
411+ linear-gradient(to bottom, transparent calc(33.33% - 0.5px), rgba(255, 255, 255, 0.35) calc(33.33% - 0.5px), rgba(255, 255, 255, 0.35) calc(33.33% + 0.5px), transparent calc(33.33% + 0.5px), transparent calc(66.67% - 0.5px), rgba(255, 255, 255, 0.35) calc(66.67% - 0.5px), rgba(255, 255, 255, 0.35) calc(66.67% + 0.5px), transparent calc(66.67% + 0.5px));
412+}
413+.crop-handle {
414+ position: absolute;
415+ width: 12px;
416+ height: 12px;
417+ margin: -6px 0 0 -6px;
418+ background: white;
419+ border: 1px solid rgba(0, 0, 0, 0.5);
420+ border-radius: 2px;
421+}
422+.crop-handle.nw,
423+.crop-handle.se {
424+ cursor: nwse-resize;
425+}
426+.crop-handle.ne,
427+.crop-handle.sw {
428+ cursor: nesw-resize;
429+}
430+.crop-handle.n,
431+.crop-handle.s {
432+ cursor: ns-resize;
433+}
434+.crop-handle.e,
435+.crop-handle.w {
436+ cursor: ew-resize;
437+}
438+.crop-scrub {
439+ display: flex;
440+ align-items: center;
441+ gap: 10px;
442+}
443+.crop-scrub input {
444+ flex: 1;
445+}
446+.crop-modal .modal-footer {
447+ align-items: center;
448+}
449+
360450 /* ---- library ---- */
361451 .library {
362452 padding: 20px 16px 40px;