// Wash: a ground that is never one flat colour and never still (LEJ-726, for the story-video push, LEJ-550). // A picture (the story's cover, a page shot, anything) fills the frame edge to edge, is turned into two brand tones, // drifts and pushes in slowly, and carries film grain. Label and stat beats sit on it in place of plain paper. // With no picture it draws a two-tone gradient that still moves, so it is safe as a default ground. // Needs only remotion and react. The colours, the fonts and the easing are set just below the imports. // The move (a two-tone photo under one line of big type, grain on top) follows HeroChapterWord in // ncoulthurst/video-engine-remotion, MIT License, Copyright (c) its author. No code from that file is used here. import React from 'react'; import { AbsoluteFill, Easing, Img, continueRender, delayRender, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from 'remotion'; // The look: paper, charcoal ink and one terracotta accent; Bricolage Grotesque headings, Hanken Grotesk body, JetBrains // Mono for code. SourcePile and Terminal read these from here, so changing them here changes all three. export const LEJ = { paper: '#FCFCFB', card: '#FFFFFF', ink: '#1E1F22', ink2: '#3E4046', stone: '#5E6067', line: '#DCDCD7', terra: '#BD4F2E', wash: '#F5E6E0', terraLine: '#E8CFC5', night: '#141517', marker: '#FFB08F', }; const C = LEJ; export const FONT = { display: '"LEJ Bricolage", Arial, sans-serif', body: '"LEJ Hanken", Arial, sans-serif', mono: '"LEJ JetBrains", Menlo, monospace', }; export const EASE = Easing.bezier(0.2, 0, 0, 1); export const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const; // The three fonts are read from public/fonts when the woff2 files are there. Without them the words are drawn in // Arial and Menlo, and the render goes on. export const loadLejFonts = () => { if (typeof document === 'undefined' || (window as any).__lejFonts) return; (window as any).__lejFonts = true; const h = delayRender('LEJ fonts'); const faces = [ new FontFace('LEJ Bricolage', `url(${staticFile('fonts/bricolage-grotesque-latin.woff2')})`, { weight: '200 800' }), new FontFace('LEJ Hanken', `url(${staticFile('fonts/hanken-grotesk-latin.woff2')})`, { weight: '100 900' }), new FontFace('LEJ JetBrains', `url(${staticFile('fonts/jetbrains-mono-latin.woff2')})`, { weight: '100 800' }), ]; Promise.all(faces.map((f) => f.load().then((ff) => (document.fonts as any).add(ff)))).then(() => continueRender(h), () => continueRender(h)); }; loadLejFonts(); // src: a path under public/ (staticFile) or an https URL. export const asset = (src: string) => (/^https?:/.test(src) ? src : staticFile(src)); export type WashTone = 'terra' | 'night' | 'paper'; // dark: where the picture's shadows go. light: where its highlights go. ink: the colour words take on this ground. export const WASH_TONES: Record = { terra: { dark: C.night, light: C.terra, ink: C.paper, soft: C.wash, accent: C.marker, scrim: '20,21,23' }, night: { dark: '#0C0D0E', light: '#6B3423', ink: C.paper, soft: C.wash, accent: C.marker, scrim: '12,13,14' }, paper: { dark: '#CC9C86', light: C.paper, ink: C.ink, soft: C.ink2, accent: C.terra, scrim: '252,252,251' }, }; export type WashProps = { image?: string | null; // a path under public/ or an https URL; none: a moving two-tone gradient tone?: WashTone; // terra (default): light words on terracotta and night. paper: dark words on a pale wash focus?: [number, number]; // the point of the picture kept in frame, 0..1 each way (default the middle) zoom?: number; // how far in the picture is once it has settled (1 = it just covers the frame; default 1.25) push?: number; // extra zoom per second after that (default 0.06; at 0.045 only the first second measured as a change) drift?: [number, number]; // travel in px per second, x and y (default [-9, -3]) arrive?: number; // the picture starts this much further out and settles in half a second (default 0.09; 0 = no settle) contrast?: number; // how hard the two tones separate (default 1.2) grain?: number; // 0 = none, 1 = heavy (default 0.2) scrim?: 'top' | 'bottom' | 'none';// a soft band of the dark tone behind where words sit (default top) // words drawn on the ground when no children are given; kept inside the area the phone apps leave clear words?: string; eyebrow?: string; sub?: string; enter?: boolean; // false: the words are there from the first frame (for a card that is cut to); default true children?: React.ReactNode; }; // One SVG noise tile, moved a little every frame so it reads as film grain, not as a texture. export const Grain: React.FC<{ amount?: number; light?: boolean }> = ({ amount = 0.2, light }) => { const f = useCurrentFrame(); const x = (f * 37) % 200, y = (f * 53) % 200; if (amount <= 0) return null; return ( ); }; // Break a line into at most `rows` rows and return the largest type size that fits `width` (0.56 em a letter on average, // Bricolage at heading weight; wide letters are allowed for by the 0.92 factor). export const fitRows = (text: string, width: number, max: number, rows: number) => { const words = String(text).split(/\s+/).filter(Boolean); let best = { size: 0, rows: [String(text)] }; for (let n = 1; n <= rows; n++) { const per = Math.max(4, Math.ceil(String(text).length / n) + (n > 1 ? 2 : 0)); const out: string[] = []; let cur = ''; for (const w of words) { if (cur && (cur + ' ' + w).length > per) { out.push(cur); cur = w; } else cur = cur ? cur + ' ' + w : w; } if (cur) out.push(cur); if (out.length > rows) continue; const em = Math.max(...out.map((r) => Math.max(r.length, 3) * 0.56)); const size = Math.min(max, (width * 0.92) / em); if (size > best.size) best = { size, rows: out }; } return best; }; export const Wash: React.FC = ({ image, tone = 'terra', focus = [0.5, 0.5], zoom = 1.25, push = 0.06, drift = [-9, -3], arrive = 0.09, contrast = 1.2, grain = 0.2, scrim = 'top', words, eyebrow, sub, enter = true, children, }) => { const f = useCurrentFrame(); const { fps, width, height } = useVideoConfig(); const t = f / fps; const T = WASH_TONES[tone]; const tall = height > width; const settle = interpolate(f, [0, Math.round(fps * 0.5)], [arrive, 0], { ...clamp, easing: EASE }); // a quick move in at the cut, then the slow push const z = Math.max(1, zoom - settle) + push * t; const dx = drift[0] * t, dy = drift[1] * t; const origin = `${focus[0] * 100}% ${focus[1] * 100}%`; // no picture: two soft pools of the tones that slide past each other const gx = 30 + 26 * Math.sin(t * 0.9), gy = 26 + 14 * Math.cos(t * 0.7); return ( {image ? ( ) : ( )} {/* two tones: highlights take the light colour, shadows are lifted to the dark colour */} {scrim !== 'none' ? ( ) : null} {children ?? (words ? : null)} ); }; // The default words: inside the area the phone apps leave clear on a tall video (x 120 to 888, y 288 to 1248), // nothing under 44 px, each word rising in turn from the third frame so the picture has changed by 0.2 s. const WashWords: React.FC<{ words: string; eyebrow?: string; sub?: string; tone: WashTone; tall: boolean; enter: boolean }> = ({ words, eyebrow, sub, tone, tall, enter }) => { const frame = useCurrentFrame(); const f = enter ? frame : 9999; const { fps, width, height } = useVideoConfig(); const T = WASH_TONES[tone]; const box = tall ? { x: 120, y: 300, w: 768 } : { x: width * 0.06, y: height * 0.16, w: width * 0.62 }; const { size, rows } = fitRows(words, box.w, tall ? 168 : 190, 3); const rule = interpolate(f, [0, 8], [0.35, 1], { ...clamp, easing: EASE }); // the label and its rule are on the first frame const n = words.split(/\s+/).filter(Boolean).length; let wi = 0; return (
{eyebrow ?
{eyebrow}
: null}
{rows.map((r, ri) => (
{r.split(' ').map((w, k) => { const i = wi++; const s = spring({ frame: f - 1 - i * 2, fps, config: { damping: 16, stiffness: 190, mass: 0.6 } }); return {w}; })}
))}
{sub ?
{sub}
: null}
); };