Files
ShortVideoGenerator/projects/water-hydrogen-bond-surface-tension/renderer/Scene.jsx
T

133 lines
6.7 KiB
React

import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {AbsoluteFill, Composition, OffthreadVideo, Sequence, continueRender,
delayRender, getInputProps, registerRoot, staticFile, useCurrentFrame} from 'remotion';
import {blenderLabels} from './scene-plan.mjs';
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 frame = useCurrentFrame(), ms = frame * 20;
const caption = data.captions.find((c) => ms >= c.start_ms && ms < c.end_ms);
const clip = data.clips.find((c) => ms >= c.start_ms && ms < c.end_ms);
const blender = data.visual_engine === 'Blender';
const labels = blender ? blenderLabels(clip, frame) : 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: 240, left: 85, right: 85,
fontSize: 64, lineHeight: 1.1, fontWeight: 800, textAlign: 'center'}}>
{data.title}
</div>}
{blender && <svg width="1080" height="1920" style={{position: 'absolute', inset: 0}}>
{labels.filter((l) => l.leader).map((l, i) => <line key={i} x1={l.x} y1={l.y + 20}
x2={l.target[0]} y2={l.target[1]} stroke="#17314c" strokeWidth="3"/>)}
</svg>}
{labels.map((l, i) => <div key={i} style={{position: 'absolute', left: blender ? l.x : l.x / 945 * 1080,
top: blender ? l.y : l.y / 1680 * 1920, transform: 'translate(-50%, -50%)', fontSize: blender ? l.size : (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.video ?? `${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);