Catalog

Edit Props (live)

config
config
config

Source

191 lines
import 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_burn

Page 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)

  • toSrc
  • accent
  • fromSrc

Meta

Updated
4/21/2026