← Catalog
Edit Props (live)
config
config
config
Source
191 linesimport React, { useEffect, useRef } from "react";
import {
AbsoluteFill,
Img,
useCurrentFrame,
useVideoConfig,
staticFile,
} from "remotion";
interface PageBurnProps {
fromSrc: string;
toSrc: string;
// Optional accent (orange default). RGB 0-255.
accent?: [number, number, number];
}
// Deterministic PRNG so frames are reproducible
function seeded(seed: number) {
let s = seed >>> 0 || 1;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 0xffffffff;
};
}
function hash2(x: number, y: number): number {
const h = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return h - Math.floor(h);
}
function noise2(x: number, y: number): number {
const ix = Math.floor(x), iy = Math.floor(y);
const fx = x - ix, fy = y - iy;
const a = hash2(ix, iy);
const b = hash2(ix + 1, iy);
const c = hash2(ix, iy + 1);
const d = hash2(ix + 1, iy + 1);
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
return a * (1 - ux) * (1 - uy) + b * ux * (1 - uy) + c * (1 - ux) * uy + d * ux * uy;
}
export const PageBurn: React.FC<PageBurnProps> = ({
fromSrc,
toSrc,
accent = [255, 120, 30],
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const frame = useCurrentFrame();
const { durationInFrames, width, height } = useVideoConfig();
const progress = Math.max(0, Math.min(1, frame / Math.max(1, durationInFrames - 1)));
// Burn expands from center radius 0 to covering the full diagonal
const maxR = Math.sqrt(width * width + height * height) * 0.6;
const burnR = progress * maxR;
const edgeBand = 180; // px of active burn band
// clipPath for the FROM image — shrinking ragged circle in the center
// We use a polygon approximation of a wavy circle
const cx = width / 2;
const cy = height / 2;
// Build a ragged circle path: the "unburned" area shrinks as progress grows
// The unburned radius starts at diagonal and shrinks to 0
const unburnedR = (1 - progress) * maxR * 1.05;
const ringPoints = 64;
const clipPoints: string[] = [];
for (let i = 0; i < ringPoints; i++) {
const a = (i / ringPoints) * Math.PI * 2;
const n = noise2(Math.cos(a) * 3 + progress * 4, Math.sin(a) * 3);
const r = Math.max(0, unburnedR + (n - 0.5) * 60);
const px = cx + Math.cos(a) * r;
const py = cy + Math.sin(a) * r;
clipPoints.push(`${((px / width) * 100).toFixed(2)}% ${((py / height) * 100).toFixed(2)}%`);
}
const clipPath = `polygon(${clipPoints.join(", ")})`;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, width, height);
if (progress <= 0 || progress >= 1) return;
// Draw the glowing burn edge using radial gradient
// The edge tracks the shrinking unburned region
const edgeR = unburnedR;
const innerR = Math.max(0, edgeR - edgeBand * 0.4);
const outerR = edgeR + edgeBand * 0.7;
const grad = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
const [ar, ag, ab] = accent;
grad.addColorStop(0, `rgba(${ar},${ag},${ab},0)`);
grad.addColorStop(0.45, `rgba(255,220,140,0.9)`);
grad.addColorStop(0.55, `rgba(${ar},${ag},${ab},0.95)`);
grad.addColorStop(0.75, `rgba(180,40,0,0.7)`);
grad.addColorStop(1, `rgba(20,0,0,0)`);
// Irregular burn ring
ctx.save();
ctx.fillStyle = grad;
ctx.beginPath();
for (let i = 0; i <= ringPoints; i++) {
const a = ((i % ringPoints) / ringPoints) * Math.PI * 2;
const n = noise2(Math.cos(a) * 3 + progress * 4, Math.sin(a) * 3);
const r = Math.max(0, edgeR + (n - 0.5) * 60);
const px = cx + Math.cos(a) * r;
const py = cy + Math.sin(a) * r;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
// Subtract inner area - draw as ring
ctx.globalCompositeOperation = "source-over";
ctx.fill();
ctx.restore();
// Ember particles along the burning edge
const rng = seeded(Math.floor(progress * 9999));
const emberCount = Math.floor(Math.sin(progress * Math.PI) * 80);
for (let i = 0; i < emberCount; i++) {
const a = rng() * Math.PI * 2;
const jitter = (rng() - 0.5) * edgeBand * 0.8;
const r = edgeR + jitter;
const ex = cx + Math.cos(a) * r;
const ey = cy + Math.sin(a) * r - rng() * 80 * progress; // rise up
const size = 1 + rng() * 5;
const alpha = 0.4 + rng() * 0.6;
const hue = 15 + rng() * 25; // orange-red
ctx.fillStyle = `hsla(${hue}, 100%, ${55 + rng() * 25}%, ${alpha})`;
ctx.beginPath();
ctx.arc(ex, ey, size, 0, Math.PI * 2);
ctx.fill();
}
// Bright core flecks
for (let i = 0; i < emberCount / 3; i++) {
const a = rng() * Math.PI * 2;
const r = edgeR + (rng() - 0.5) * 40;
const ex = cx + Math.cos(a) * r;
const ey = cy + Math.sin(a) * r;
ctx.fillStyle = `rgba(255, 240, 180, ${0.5 + rng() * 0.5})`;
ctx.beginPath();
ctx.arc(ex, ey, 1 + rng() * 2, 0, Math.PI * 2);
ctx.fill();
}
}, [frame, width, height, progress, accent, cx, cy, unburnedR]);
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
{/* Destination (next scene) underneath */}
<Img
src={staticFile(toSrc)}
style={{ width: "100%", height: "100%", objectFit: "cover", position: "absolute" }}
/>
{/* From scene clipped to shrinking ragged circle */}
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
clipPath: progress >= 1 ? "polygon(0 0, 0 0, 0 0)" : clipPath,
WebkitClipPath: progress >= 1 ? "polygon(0 0, 0 0, 0 0)" : clipPath,
}}
>
<Img
src={staticFile(fromSrc)}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
{/* Fire edge + embers overlay */}
<canvas
ref={canvasRef}
width={width}
height={height}
style={{
position: "absolute",
width: "100%",
height: "100%",
mixBlendMode: "screen",
pointerEvents: "none",
}}
/>
</AbsoluteFill>
);
};
Schema (Zod)
// Auto-extracted from Props interface — see source for authoritative types.
// Component: PageBurn
// 3 props detected · 0 port(s)
Page Burn
page_burnPage Burn — Andy's hand-crafted Remotion scene. 3 props. no ports detected — source-only preview.
Workflow Inputs (0)
This block has no workflow-connected inputs.
Config Fields (3)
toSrcaccentfromSrc
Meta
- Updated
- 4/21/2026