/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / shared / layout.ts
102 lines · 4.1 KBCodeBlameHistory
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 1// Output geometry for a take, in output pixels. Used both by the ffmpeg export and by the
2// browser preview, so that what you see while editing is what gets rendered.
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 4import type { BackgroundMode, CameraShape, Crop, Layout, OutputSettings } from './types.ts';
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 5import { backgroundOf } from './types.ts';
7export interface Box {
8 x: number;
9 y: number;
10 w: number;
11 h: number;
14export interface Geometry {
15 W: number;
16 H: number;
17 screen: Box | null; // screen is always fit (letterboxed) inside its box
18 camera: Camera | null;
21export interface Camera {
22 box: Box;
23 fit: 'cover' | 'contain';
24 shape: CameraShape;
25 radius: number;
26 // Effective background: 'cutout' (no box, just the person) only applies to picture-in-picture;
27 // elsewhere it falls back to 'color'.
28 background: BackgroundMode;
29 color: string;
30 blurSigma: number; // in output pixels
33export const FPS = 30;
35const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
37export function outputSize(output: OutputSettings) {
38 const H = output.height;
39 return { W: even((H * 16) / 9), H };
42export function computeGeometry(layout: Layout, output: OutputSettings, hasScreen: boolean, hasCamera: boolean): Geometry {
43 const { W, H } = outputSize(output);
44 const full: Box = { x: 0, y: 0, w: W, h: H };
45 let mode = layout.mode;
46 if (!hasCamera) mode = 'screen-only';
47 else if (!hasScreen) mode = 'camera-only';
49 const bg = backgroundOf(layout);
50 const background: BackgroundMode = bg.mode === 'cutout' && mode !== 'pip' ? 'color' : bg.mode;
51 const camera = (box: Box, fit: 'cover' | 'contain', shape: CameraShape, radius: number): Camera => ({
52 box,
53 fit,
54 shape,
55 radius,
56 background,
57 color: bg.color,
58 blurSigma: Math.round((bg.blur * H) / 1080 * 10) / 10,
59 });
61 if (mode === 'screen-only') return { W, H, screen: full, camera: null };
62 if (mode === 'camera-only') return { W, H, screen: null, camera: camera(full, 'contain', 'rect', 0) };
64 const left = layout.corner === 'tl' || layout.corner === 'bl';
65 if (mode === 'side-by-side') {
66 const cw = even(W * 0.3);
67 const camBox: Box = { x: left ? 0 : W - cw, y: 0, w: cw, h: H };
68 const scrBox: Box = { x: left ? cw : 0, y: 0, w: W - cw, h: H };
69 return { W, H, screen: scrBox, camera: camera(camBox, 'cover', 'rect', 0) };
70 }
72 // picture-in-picture
73 const margin = even(W * 0.025);
74 const cw = even(W * Math.min(0.5, Math.max(0.08, layout.size)));
75 const top = layout.corner === 'tl' || layout.corner === 'tr';
76 if (background === 'cutout') {
77 // The person is cut off at the bottom of the camera frame, so sit that edge on the bottom of the video.
78 const ch = even((cw * 9) / 16);
79 const box: Box = { x: left ? margin : W - margin - cw, y: top ? margin : H - ch, w: cw, h: ch };
80 return { W, H, screen: full, camera: camera(box, 'cover', 'rect', 0) };
81 }
82 const ch = layout.shape === 'circle' ? cw : even((cw * 9) / 16);
83 const box: Box = { x: left ? margin : W - margin - cw, y: top ? margin : H - margin - ch, w: cw, h: ch };
84 const radius = layout.shape === 'circle' ? cw / 2 : layout.shape === 'rounded' ? Math.round(Math.min(cw, ch) * 0.12) : 0;
85 return { W, H, screen: full, camera: camera(box, 'cover', layout.shape, radius) };
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.
91export 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 } };
↑↓ move↵openescclose