// SourcePile: a story's source pages land as a pile, one at a time, the first inside 0.2 s (LEJ-726, for LEJ-550). // A line of words sits above from the first frame; the caption under it names the page on top. Pages rise from below, // so they never cross the words. Every page keeps scrolling a little once it // has landed, and the whole pile is pushed in slowly, so the picture changes in every second it is up. // Needs only remotion and react, and Wash.tsx beside it: the ground, the colours and the fonts come from there. // The idea (clippings dropping onto a stack) came from an open-source piece that states no licence, so nothing was // copied from it: this file is written from scratch. import React from 'react'; import { AbsoluteFill, Img, spring, useCurrentFrame, useVideoConfig } from 'remotion'; import { Wash, fitRows, type WashTone, WASH_TONES, LEJ as C, FONT, asset } from './Wash'; export type PilePage = { image: string; label?: string }; export type SourcePileProps = { pages: PilePage[]; // one to six page pictures (phone-width shots work best on a tall video) words?: string; // the line above the pile eyebrow?: string; // the small label above the line (default SOURCES) first?: number; // seconds until the first page lands (default 0.08: it is on screen by 0.2 s) every?: number; // seconds between pages (default 0.5) tone?: WashTone; // the ground: paper (default), terra or night ground?: string | null; // a picture for the ground (the story's cover); none: a moving two-tone gradient count?: boolean; // show "2/4" beside the caption (default true) }; const TILT = [-5, 3.6, -2.4, 1.6, -1, 0.6]; const DX = [-0.040, 0.034, -0.020, 0.016, -0.008, 0]; export const SourcePile: React.FC = ({ pages, words = 'Where this comes from', eyebrow = 'SOURCES', first = 0.08, every = 0.5, tone = 'paper', ground = null, count = true }) => { const f = useCurrentFrame(); const { fps, width, height } = useVideoConfig(); const t = f / fps; const tall = height > width; const T = WASH_TONES[tone]; const list = (pages || []).slice(0, 6); const F0 = Math.round(first * fps), GAP = Math.max(1, Math.round(every * fps)); const top = Math.max(0, Math.min(list.length - 1, Math.floor((f - F0) / GAP))); // words in the area the phone apps leave clear; the pile is a picture, so it may run under their buttons const box = tall ? { x: 120, y: 300, w: 768 } : { x: width * 0.05, y: height * 0.14, w: width * 0.36 }; const { size, rows } = fitRows(words, box.w, tall ? 96 : 84, 2); const card = tall ? { w: width * 0.80, h: height * 0.56, cx: width * 0.5, y: 744 } : { w: width * 0.40, h: height * 0.86, cx: width * 0.72, y: height * 0.07 }; const step = tall ? 44 : 26; const cam = 1 + 0.012 * t; return ( {list.map((p, i) => { const at = F0 + i * GAP; if (f < at) return null; const s = spring({ frame: f - at, fps, config: { damping: 14, stiffness: 170, mass: 0.7 } }); const scroll = -Math.min(160, ((f - at) / fps) * 26); // the page keeps moving once it has landed return (
); })}
{eyebrow}
{rows.map((r, i) =>
{r}
)}
{list[top]?.label ? (
{list[top].label}
{count && list.length > 1 ?
{top + 1}/{list.length}
: null}
) : null}
); };