// Output geometry for a take, in output pixels. Used both by the ffmpeg export and by the // browser preview, so that what you see while editing is what gets rendered. import type { BackgroundMode, CameraShape, Layout, OutputSettings } from './types.ts'; import { backgroundOf } from './types.ts'; export interface Box { x: number; y: number; w: number; h: number; } export interface Geometry { W: number; H: number; screen: Box | null; // screen is always fit (letterboxed) inside its box camera: Camera | null; } export interface Camera { box: Box; fit: 'cover' | 'contain'; shape: CameraShape; radius: number; // Effective background: 'cutout' (no box, just the person) only applies to picture-in-picture; // elsewhere it falls back to 'color'. background: BackgroundMode; color: string; blurSigma: number; // in output pixels } export const FPS = 30; const even = (n: number) => Math.max(2, Math.round(n / 2) * 2); export function outputSize(output: OutputSettings) { const H = output.height; return { W: even((H * 16) / 9), H }; } export function computeGeometry(layout: Layout, output: OutputSettings, hasScreen: boolean, hasCamera: boolean): Geometry { const { W, H } = outputSize(output); const full: Box = { x: 0, y: 0, w: W, h: H }; let mode = layout.mode; if (!hasCamera) mode = 'screen-only'; else if (!hasScreen) mode = 'camera-only'; const bg = backgroundOf(layout); const background: BackgroundMode = bg.mode === 'cutout' && mode !== 'pip' ? 'color' : bg.mode; const camera = (box: Box, fit: 'cover' | 'contain', shape: CameraShape, radius: number): Camera => ({ box, fit, shape, radius, background, color: bg.color, blurSigma: Math.round((bg.blur * H) / 1080 * 10) / 10, }); if (mode === 'screen-only') return { W, H, screen: full, camera: null }; if (mode === 'camera-only') return { W, H, screen: null, camera: camera(full, 'contain', 'rect', 0) }; const left = layout.corner === 'tl' || layout.corner === 'bl'; if (mode === 'side-by-side') { const cw = even(W * 0.3); const camBox: Box = { x: left ? 0 : W - cw, y: 0, w: cw, h: H }; const scrBox: Box = { x: left ? cw : 0, y: 0, w: W - cw, h: H }; return { W, H, screen: scrBox, camera: camera(camBox, 'cover', 'rect', 0) }; } // picture-in-picture const margin = even(W * 0.025); const cw = even(W * Math.min(0.5, Math.max(0.08, layout.size))); const top = layout.corner === 'tl' || layout.corner === 'tr'; if (background === 'cutout') { // The person is cut off at the bottom of the camera frame, so sit that edge on the bottom of the video. const ch = even((cw * 9) / 16); const box: Box = { x: left ? margin : W - margin - cw, y: top ? margin : H - ch, w: cw, h: ch }; return { W, H, screen: full, camera: camera(box, 'cover', 'rect', 0) }; } const ch = layout.shape === 'circle' ? cw : even((cw * 9) / 16); const box: Box = { x: left ? margin : W - margin - cw, y: top ? margin : H - margin - ch, w: cw, h: ch }; const radius = layout.shape === 'circle' ? cw / 2 : layout.shape === 'rounded' ? Math.round(Math.min(cw, ch) * 0.12) : 0; return { W, H, screen: full, camera: camera(box, 'cover', layout.shape, radius) }; }