126 lines
6.2 KiB
React
126 lines
6.2 KiB
React
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
|
import {AbsoluteFill, Composition, OffthreadVideo, Sequence, continueRender,
|
|
delayRender, getInputProps, registerRoot, staticFile, useCurrentFrame} from 'remotion';
|
|
|
|
const clamp = (n) => Math.max(0, Math.min(1, n));
|
|
const ease = (n) => n * n * (3 - 2 * n);
|
|
const ochre = (p, i) => p[i] > p[i + 1] + 8 && p[i + 1] > 150 && p[i + 1] > p[i + 2] + 12;
|
|
const loaded = new Map();
|
|
function loadImage(name) {
|
|
if (!loaded.has(name)) loaded.set(name, new Promise((resolve, reject) => {
|
|
const im = new Image(); im.onload = () => resolve(im); im.onerror = reject;
|
|
im.src = staticFile(name);
|
|
}));
|
|
return loaded.get(name);
|
|
}
|
|
|
|
function Motion({clip}) {
|
|
const frame = useCurrentFrame();
|
|
const ref = useRef(null);
|
|
const [pair, setPair] = useState(null);
|
|
const [handle] = useState(() => delayRender('load actual start/end PNG'));
|
|
useEffect(() => {
|
|
Promise.all([loadImage(clip.a), loadImage(clip.b)]).then(([a, b]) => {
|
|
const c = document.createElement('canvas'); c.width = 945; c.height = 1680;
|
|
const ctx = c.getContext('2d', {willReadFrequently: true});
|
|
ctx.drawImage(a, 0, 0); const pa = ctx.getImageData(0, 0, 945, 1680);
|
|
ctx.clearRect(0, 0, 945, 1680); ctx.drawImage(b, 0, 0);
|
|
const pb = ctx.getImageData(0, 0, 945, 1680);
|
|
const pixels = [];
|
|
for (let i = 0; i < pa.data.length; i += 4) {
|
|
if (ochre(pa.data, i) || ochre(pb.data, i)) pixels.push(i);
|
|
}
|
|
setPair({a, b, pa, pb, pixels}); continueRender(handle);
|
|
}).catch((e) => {throw e;});
|
|
}, [clip.a, clip.b, handle]);
|
|
useLayoutEffect(() => {
|
|
if (!pair || !ref.current) return;
|
|
const ctx = ref.current.getContext('2d');
|
|
const p = ease(clamp((frame * 20 - (clip.plan.delayMs ?? 0)) /
|
|
(clip.frames * 20 - (clip.plan.delayMs ?? 0)) * 1.35));
|
|
ctx.clearRect(0, 0, 945, 1680);
|
|
if (p >= 1 || frame === clip.frames - 1) {ctx.drawImage(pair.b, 0, 0); return;}
|
|
ctx.drawImage(pair.a, 0, 0);
|
|
if (!p || clip.plan.kind === 'hold') return;
|
|
let annotationPixels;
|
|
if (clip.plan.kind === 'annotation' || clip.plan.kind === 'deform') {
|
|
// Only ochre explanatory pixels change. All source atoms/internal bonds remain intact.
|
|
const out = new ImageData(new Uint8ClampedArray(pair.pa.data), 945, 1680);
|
|
for (const i of pair.pixels) {
|
|
const coordinate = clip.plan.direction === 'x' ? (i / 4 % 945) / 945 :
|
|
clip.plan.direction === 'y' ? Math.floor(i / 4 / 945) / 1680 : 0;
|
|
const t = clamp(p * (clip.plan.direction ? 1.4 : 1) - coordinate * 0.4);
|
|
for (let k = 0; k < 3; k++) out.data[i + k] =
|
|
pair.pa.data[i + k] * (1 - t) + pair.pb.data[i + k] * t;
|
|
}
|
|
ctx.putImageData(out, 0, 0);
|
|
annotationPixels = out.data;
|
|
}
|
|
if (clip.plan.kind === 'deform') {
|
|
const [a, b] = clip.boxes;
|
|
const box = a.map((v, i) => v + (b[i] - v) * p);
|
|
ctx.fillStyle = `rgb(${clip.background.join(',')})`;
|
|
// Clear all of the original contour, including non-elliptical antialiasing.
|
|
ctx.fillRect(a[0] - 3, a[1] - 3, a[2] + 6, a[3] + 6);
|
|
ctx.save(); ctx.beginPath(); ctx.ellipse(box[0] + box[2] / 2, box[1] + box[3] / 2,
|
|
box[2] / 2, box[3] / 2, 0, 0, Math.PI * 2); ctx.clip();
|
|
ctx.drawImage(pair.a, ...a, ...box);
|
|
ctx.globalAlpha = p; ctx.drawImage(pair.b, ...b, ...box); ctx.restore();
|
|
// Preserve external energy/arrow pixels if their callout edge overlaps the body bounding box.
|
|
for (const i of pair.pixels) {
|
|
ctx.fillStyle = `rgb(${annotationPixels[i]},${annotationPixels[i + 1]},${annotationPixels[i + 2]})`;
|
|
ctx.fillRect(i / 4 % 945, Math.floor(i / 4 / 945), 1, 1);
|
|
}
|
|
}
|
|
if (clip.plan.regions?.length) {
|
|
ctx.save(); ctx.beginPath();
|
|
for (const r of clip.plan.regions) ctx.rect(...r);
|
|
ctx.clip();
|
|
ctx.globalAlpha = p; ctx.drawImage(pair.b, 0, 0); ctx.restore();
|
|
}
|
|
}, [frame, pair, clip]);
|
|
return <canvas ref={ref} width={945} height={1680} style={{width: '100%', height: '100%'}}/>;
|
|
}
|
|
|
|
function TextLayer({data}) {
|
|
const ms = useCurrentFrame() * 20;
|
|
const caption = data.captions.find((c) => ms >= c.start_ms && ms < c.end_ms);
|
|
const labels = data.labels.filter((l) => ms >= l.start && ms < l.end);
|
|
return <AbsoluteFill style={{fontFamily: 'Malgun Gothic, sans-serif', color: '#17314c'}}>
|
|
{ms < 7920 && <div style={{position: 'absolute', top: 35, left: 85, right: 85,
|
|
fontSize: 64, lineHeight: 1.1, fontWeight: 800, textAlign: 'center'}}>
|
|
물방울은 왜<br/>동그랗게 뭉칠까?
|
|
</div>}
|
|
{labels.map((l, i) => <div key={i} style={{position: 'absolute', left: l.x / 945 * 1080,
|
|
top: l.y / 1680 * 1920, transform: 'translate(-50%, -50%)', fontSize: (l.size ?? 36) / 945 * 1080,
|
|
fontWeight: 700, whiteSpace: 'nowrap', background: l.text.startsWith('δ') ? 'transparent' : '#fffdf2ee',
|
|
padding: l.text.startsWith('δ') ? 0 : '9px 16px', borderRadius: 12}}>{l.text}</div>)}
|
|
{caption && <div style={{position: 'absolute', left: 80, right: 80, bottom: 140,
|
|
minHeight: 160, borderRadius: 24, padding: '25px 28px', boxSizing: 'border-box',
|
|
background: '#17314cef', color: '#fffdf2', fontSize: 52, fontWeight: 700,
|
|
lineHeight: 1.4, whiteSpace: 'pre-line', textAlign: 'center', display: 'flex',
|
|
alignItems: 'center', justifyContent: 'center'}}>{caption.text}</div>}
|
|
</AbsoluteFill>;
|
|
}
|
|
|
|
function Video({data, composed = false}) {
|
|
return <AbsoluteFill style={{background: '#fffdf2'}}>
|
|
{data.clips.map((clip) => <Sequence key={clip.id} from={clip.start_ms / 20} durationInFrames={clip.frames}>
|
|
{composed ? <OffthreadVideo src={staticFile(`${clip.id}.mp4`)} muted style={{width: '100%', height: '100%'}}/>
|
|
: <Motion clip={clip}/>}
|
|
</Sequence>)}
|
|
{composed && <TextLayer data={data}/>}
|
|
</AbsoluteFill>;
|
|
}
|
|
|
|
function Root() {
|
|
const {data} = getInputProps();
|
|
return <>
|
|
<Composition id="Motion" component={Video} width={1080} height={1920} fps={50}
|
|
durationInFrames={5528} defaultProps={{data}}/>
|
|
<Composition id="Composed" component={Video} width={1080} height={1920} fps={50}
|
|
durationInFrames={5528} defaultProps={{data, composed: true}}/>
|
|
</>;
|
|
}
|
|
registerRoot(Root);
|