import type { FC } from "react"; import { useCurrentFrame } from "remotion"; import { DRAW } from "../brand/tokens"; import { inOut, mix, out, progress } from "../illustrations/motion"; import { hopAt, type Point } from "../lib/hop"; import { FilmStage, type Caption } from "./FilmStage"; export const ORDER_FILM_FRAMES = 690; /** * One continuous shot, told by the camera. Close up, a dot drifts between two squares: ping-pong. * The camera pulls back: it is one of many, a whole office carrying things from tool to tool. * Everything stops, the dots sink onto a line, and the lines carry the work by themselves. * The camera returns to the two squares, so the film loops. */ const T = { reveal: 75, wide: 195, freeze: 310, sink: 345, lines: 365, flow: 440, cta: 560, fold: 630, } as const; const CAPTIONS: readonly Caption[] = [ { id: "hook", from: 0, to: T.reveal + 20 }, { id: "reveal", from: T.reveal + 30, to: T.wide + 30 }, { id: "problem", from: T.wide + 30, to: T.freeze + 20 }, { id: "turn", from: T.sink, to: T.flow + 20 }, { id: "calm", from: T.flow + 20, to: T.cta }, { id: "cta", from: T.cta, to: ORDER_FILM_FRAMES, button: true }, ]; const COLS = 4; const HERO_ROW = 2; const PITCH = { x: 220, y: 190 } as const; const TILE = 96; const ZOOM_IN = 2.6; const ZOOM_OUT = 0.8; /** Height of a hop as a share of its length. */ const ARC = 0.22; const CLOSE_HOP_FRAMES = 30; /** The film starts in mid-flight: the first frame is the preview image. */ const CLOSE_PHASE = 15; const colX = (col: number): number => (col - (COLS - 1) / 2) * PITCH.x; const rowY = (row: number): number => (row - HERO_ROW) * PITCH.y; const stop = (row: number, col: number): Point => ({ x: colX(col), y: rowY(row) }); /** Each row has someone carrying things round its four tools, in their own order and pace. */ const WALKERS = [ { route: [0, 1, 2, 3], hopFrames: 27, offset: 7 }, { route: [2, 0, 3, 1], hopFrames: 32, offset: 40 }, { route: [2, 3, 0, 1], hopFrames: 29, offset: 0 }, { route: [3, 2, 1, 0], hopFrames: 25, offset: 19 }, { route: [1, 3, 0, 2], hopFrames: 30, offset: 55 }, ] as const; /** 0 while the camera is close, 1 when it has pulled back. */ const pulledBack = (frame: number): number => inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, ORDER_FILM_FRAMES))); /** Time as the walkers feel it: it runs, then slows to a halt at the freeze. */ const walkTime = (frame: number): number => { if (frame < T.freeze) return frame; const u = progress(frame, T.freeze, T.freeze + 30); return T.freeze + 15 * (1 - (1 - u) ** 2); }; const walkerAt = (row: number, frame: number): Point => { const walker = WALKERS[row]; if (!walker) return stop(row, 0); const time = walkTime(frame); // The one in the middle row plays ping-pong between the two inner squares while the camera is close. if (row === HERO_ROW && (time < T.reveal || frame >= T.fold)) { const loopTime = frame >= T.fold ? frame - ORDER_FILM_FRAMES : time; return hopAt(loopTime + CLOSE_PHASE, [stop(row, 1), stop(row, 2)], CLOSE_HOP_FRAMES, ARC * 1.4); } const start = row === HERO_ROW ? T.reveal : -walker.offset; return hopAt(time - start, walker.route.map((col) => stop(row, col)), walker.hopFrames, ARC); }; /** The picture alone, without stage and statements: also used frozen, as an image post. */ export const OrderDrawing: FC = () => { const frame = useCurrentFrame(); const back = pulledBack(frame); const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back; /** Everything outside the opening picture fades in as the camera pulls back. */ const others = progress(back, 0.05, 0.5); const hair = 2.2 / zoom; // Back in the air for the return to the opening picture. const sink = frame >= T.fold ? 0 : inOut(progress(frame, T.sink, T.sink + 30)); const folding = 1 - progress(frame, T.fold, T.fold + 24); return ( {WALKERS.map((_, row) => { const hero = row === HERO_ROW; const y = rowY(row); const away = Math.abs(row - HERO_ROW); // The line that replaces the walking. const lineStart = T.lines + away * 12; const drawn = out(progress(frame, lineStart, lineStart + 45)) * folding; const lineEnd = mix(colX(0), colX(COLS - 1), drawn); // Work travelling along the line by itself. const sincePulse = frame - (T.flow + away * 10); const pulseT = sincePulse < 0 ? -1 : (sincePulse % 80) / 55; const pulseX = pulseT >= 0 && pulseT <= 1 ? mix(colX(0), colX(COLS - 1), inOut(pulseT)) : null; // The walker, and the light it trails. const walkerShown = (hero ? 1 : others) * (1 - progress(frame, lineStart, lineStart + 18)) + (hero ? progress(frame, T.fold + 28, ORDER_FILM_FRAMES - 6) : 0); const place = (at: number): Point => { const point = walkerAt(row, at); return { x: point.x, y: mix(point.y, y, sink) }; }; const walker = place(frame); const radius = 12 / Math.sqrt(zoom); return ( {drawn > 0 && ( )} {Array.from({ length: COLS }, (_unused, col) => { const x = colX(col); const opening = hero && (col === 1 || col === 2); const touched = walkerShown > 0.5 && Math.hypot(walker.x - x, walker.y - y) < TILE * 0.42; const lit = pulseX === null ? 0 : Math.max(0, 1 - Math.abs(pulseX - x) / 110) * folding; return ( ); })} {pulseX !== null && folding > 0 && ( )} {walkerShown > 0 && ( {[1, 2, 3, 4, 5].map((k) => { const ghost = place(frame - k * 2.2); return ; })} )} ); })} ); }; /** Automation, as one continuous shot: from ping-pong to tools that talk to each other. */ export const OrderFilm: FC = () => ( );