/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / src / components / Stage.tsx
137 lines · 6.0 KBCodeBlameHistory
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.
3import { useEffect, useRef, useState, type CSSProperties } from 'react';
4import type { Take, Video } from '../../shared/types.ts';
5import { computeGeometry, outputSize, type Box, type Camera } from '../../shared/layout.ts';
6import { takeFileUrl } from '../api.ts';
7import { usePlayerActiveTake, type TimelinePlayer } from '../lib/player.ts';
9const boxStyle = (b: Box): CSSProperties => ({ left: b.x, top: b.y, width: b.w, height: b.h });
11export function Stage({ video, player }: { video: Video; player: TimelinePlayer }) {
12 const active = usePlayerActiveTake(player);
13 const outerRef = useRef<HTMLDivElement>(null);
14 const [scale, setScale] = useState(0.4);
15 const { W, H } = outputSize(video.output);
17 useEffect(() => {
18 const el = outerRef.current;
19 if (!el) return;
20 const ro = new ResizeObserver(() => setScale(el.clientWidth / W));
21 ro.observe(el);
22 return () => ro.disconnect();
23 }, [W]);
25 const takes = video.takes.filter((t) => t.status === 'ready');
26 return (
27 <div className="stage" ref={outerRef} style={{ aspectRatio: `${W} / ${H}` }} onClick={() => player.toggle()}>
28 <div className="stage-inner" style={{ width: W, height: H, transform: `scale(${scale})` }}>
29 {takes.map((t) => {
30 const g = computeGeometry(video.layout, video.output, t.tracks.screen, t.tracks.camera);
31 return (
32 <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} />}
45 </div>
46 );
47 })}
48 </div>
49 {!active && <div className="stage-empty">Nothing in the timeline yet</div>}
50 </div>
51 );
54// The camera box: the original camera, or (once background removal has run) the chosen background
55// with the cutout on top. The camera element stays mounted even when hidden, since it carries the audio.
56function CameraLayer({ videoId, take, cam, player }: { videoId: string; take: Take; cam: Camera | null; player: TimelinePlayer }) {
57 const camSrc = takeFileUrl(videoId, take.id, 'camera');
58 const muted = take.audio !== 'camera';
59 if (!cam) return <video ref={player.refFor(take.id, 'camera')} src={camSrc} muted={muted} preload="auto" playsInline style={{ display: 'none' }} />;
60 const hasCutout = cam.background !== 'none' && take.matte?.status === 'done';
61 const bg = hasCutout ? cam.background : 'none';
62 const media: CSSProperties = { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: cam.fit };
63 return (
64 <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} />}
79 </div>
80 );
83function drawFit(ctx: CanvasRenderingContext2D, el: HTMLVideoElement, b: Box, fit: 'contain' | 'cover') {
84 const vw = el.videoWidth;
85 const vh = el.videoHeight;
86 if (!vw || !vh) return;
87 if (fit === 'contain') {
88 const s = Math.min(b.w / vw, b.h / vh);
89 const w = vw * s;
90 const h = vh * s;
91 ctx.drawImage(el, b.x + (b.w - w) / 2, b.y + (b.h - h) / 2, w, h);
92 } else {
93 const s = Math.max(b.w / vw, b.h / vh);
94 const sw = b.w / s;
95 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);
97 }
100// Render the current preview frame to a 640-wide JPEG (for the thumbnail).
101export async function captureFrame(video: Video, player: TimelinePlayer): Promise<Blob | null> {
102 const takeId = player.getSnapshot().activeTakeId;
103 const take = video.takes.find((t) => t.id === takeId);
104 if (!take) return null;
105 const els = player.elementsFor(take.id);
106 const g = computeGeometry(video.layout, video.output, take.tracks.screen, take.tracks.camera);
107 const canvas = document.createElement('canvas');
108 canvas.width = 640;
109 canvas.height = Math.round((640 * g.H) / g.W);
110 const ctx = canvas.getContext('2d')!;
111 ctx.scale(640 / g.W, 640 / g.W);
112 ctx.fillStyle = '#000';
113 ctx.fillRect(0, 0, g.W, g.H);
114 if (g.screen && els.screen) drawFit(ctx, els.screen, g.screen, 'contain');
115 if (g.camera && els.camera) {
116 const { box, fit, radius, background, color, blurSigma } = g.camera;
117 const cutout = background !== 'none' && take.matte?.status === 'done' ? els.cutout : undefined;
118 ctx.save();
119 ctx.beginPath();
120 ctx.roundRect(box.x, box.y, box.w, box.h, radius);
121 ctx.clip();
122 if (!cutout) drawFit(ctx, els.camera, box, fit);
123 else {
124 if (background === 'color') {
125 ctx.fillStyle = color;
126 ctx.fillRect(box.x, box.y, box.w, box.h);
127 } else if (background === 'blur') {
128 ctx.filter = `blur(${(blurSigma * 640) / g.W}px)`;
129 drawFit(ctx, els.camera, box, fit);
130 ctx.filter = 'none';
131 }
132 drawFit(ctx, cutout, box, fit);
133 }
134 ctx.restore();
135 }
136 return new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.88));
↑↓ move↵openescclose