f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 1// Composite preview of the timeline, laid out in output pixels and scaled to fit, so it matches the export.
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 3import { useEffect, useRef, useState, type CSSProperties, type SyntheticEvent } from 'react';
4import type { Crop, Take, Video } from '../../shared/types.ts';
5import { cropOf } from '../../shared/types.ts';
6import { computeGeometry, outputSize, placeCropped, type Box, type Camera } from '../../shared/layout.ts';
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 7import { takeFileUrl } from '../api.ts';
8import { usePlayerActiveTake, type TimelinePlayer } from '../lib/player.ts';
10const boxStyle = (b: Box): CSSProperties => ({ left: b.x, top: b.y, width: b.w, height: b.h });
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 12type Dims = { w: number; h: number } | null;
14// The intrinsic size of a <video>, which the crop placement needs; it can change mid-stream.
15function 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}
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.
26function 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}
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 40export function Stage({ video, player }: { video: Video; player: TimelinePlayer }) {
41 const active = usePlayerActiveTake(player);
42 const outerRef = useRef<HTMLDivElement>(null);
43 const [scale, setScale] = useState(0.4);
44 const { W, H } = outputSize(video.output);
46 useEffect(() => {
47 const el = outerRef.current;
48 if (!el) return;
49 const ro = new ResizeObserver(() => setScale(el.clientWidth / W));
50 ro.observe(el);
51 return () => ro.disconnect();
52 }, [W]);
54 const takes = video.takes.filter((t) => t.status === 'ready');
55 return (
56 <div className="stage" ref={outerRef} style={{ aspectRatio: `${W} / ${H}` }} onClick={() => player.toggle()}>
57 <div className="stage-inner" style={{ width: W, height: H, transform: `scale(${scale})` }}>
58 {takes.map((t) => {
59 const g = computeGeometry(video.layout, video.output, t.tracks.screen, t.tracks.camera);
60 return (
61 <div key={t.id} style={{ display: t.id === active ? 'block' : 'none' }}>
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 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} />}
65 );
66 })}
67 </div>
68 {!active && <div className="stage-empty">Nothing in the timeline yet</div>}
69 </div>
70 );
71}
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 73// The screen track fit (letterboxed) into its box. The element stays mounted even when hidden, since it may carry the audio.
74function 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}
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 96// The camera box: the original camera, or (once background removal has run) the chosen background
97// with the cutout on top. The camera element stays mounted even when hidden, since it carries the audio.
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 98function CameraLayer({ videoId, take, cam, crop, player }: { videoId: string; take: Take; cam: Camera | null; crop: Crop | undefined; player: TimelinePlayer }) {
99 const [dims, onDims] = useVideoDims();
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 100 const camSrc = takeFileUrl(videoId, take.id, 'camera');
101 const muted = take.audio !== 'camera';
102 if (!cam) return <video ref={player.refFor(take.id, 'camera')} src={camSrc} muted={muted} preload="auto" playsInline style={{ display: 'none' }} />;
103 const hasCutout = cam.background !== 'none' && take.matte?.status === 'done';
104 const bg = hasCutout ? cam.background : 'none';
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 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);
108 <div className="layer" style={{ ...boxStyle(cam.box), borderRadius: cam.radius, overflow: 'hidden', background: bg === 'color' ? cam.color : bg === 'cutout' ? 'transparent' : '#000' }}>
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 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>
128 );
129}
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 131// Draw the (cropped) frame of `el` fit into box `b`; the crop is in fractions of the frame.
132function 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;
139 if (fit === 'contain') {
140 const s = Math.min(b.w / vw, b.h / vh);
141 const w = vw * s;
142 const h = vh * s;
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 143 ctx.drawImage(el, cx, cy, vw, vh, b.x + (b.w - w) / 2, b.y + (b.h - h) / 2, w, h);
145 const s = Math.max(b.w / vw, b.h / vh);
146 const sw = b.w / s;
147 const sh = b.h / s;
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 148 ctx.drawImage(el, cx + (vw - sw) / 2, cy + (vh - sh) / 2, sw, sh, b.x, b.y, b.w, b.h);
150}
152// Render the current preview frame to a 640-wide JPEG (for the thumbnail).
153export async function captureFrame(video: Video, player: TimelinePlayer): Promise<Blob | null> {
154 const takeId = player.getSnapshot().activeTakeId;
155 const take = video.takes.find((t) => t.id === takeId);
156 if (!take) return null;
157 const els = player.elementsFor(take.id);
158 const g = computeGeometry(video.layout, video.output, take.tracks.screen, take.tracks.camera);
159 const canvas = document.createElement('canvas');
160 canvas.width = 640;
161 canvas.height = Math.round((640 * g.H) / g.W);
162 const ctx = canvas.getContext('2d')!;
163 ctx.scale(640 / g.W, 640 / g.W);
164 ctx.fillStyle = '#000';
165 ctx.fillRect(0, 0, g.W, g.H);
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 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);
f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 169 if (g.camera && els.camera) {
170 const { box, fit, radius, background, color, blurSigma } = g.camera;
171 const cutout = background !== 'none' && take.matte?.status === 'done' ? els.cutout : undefined;
172 ctx.save();
173 ctx.beginPath();
174 ctx.roundRect(box.x, box.y, box.w, box.h, radius);
175 ctx.clip();
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 176 if (!cutout) drawFit(ctx, els.camera, box, fit, cameraCrop);
178 if (background === 'color') {
179 ctx.fillStyle = color;
180 ctx.fillRect(box.x, box.y, box.w, box.h);
181 } else if (background === 'blur') {
182 ctx.filter = `blur(${(blurSigma * 640) / g.W}px)`;
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 183 drawFit(ctx, els.camera, box, fit, cameraCrop);
185 }
5bb2925Crop the screen and camera of each take; default output to 720pJeremy Magland 186 drawFit(ctx, cutout, box, fit, cameraCrop);
188 ctx.restore();
189 }
190 return new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.88));
191}