Adds twelve image posts made the same way as the films: six posters (one picture of each film with its idea in two lines) and six cards that answer a question clients ask before the first call. The answers come from the questions and principles on the website. The posting plan is data (six weeks, film, card and poster per week), and the export writes the library in that order, by language, week and day, with the text to post. All video posts now open with the hook of their film. The reasoning is in plans/content-strategy.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
7.7 KiB
TypeScript
197 lines
7.7 KiB
TypeScript
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 (
|
|
<g transform={`scale(${zoom})`}>
|
|
{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 (
|
|
<g key={row}>
|
|
{drawn > 0 && (
|
|
<path
|
|
d={`M${colX(0)} ${y}H${lineEnd}`}
|
|
stroke={DRAW.fg}
|
|
strokeWidth={hair * 1.3}
|
|
strokeLinecap="round"
|
|
opacity={hero ? 1 : others}
|
|
style={{ filter: `drop-shadow(0 0 ${6 / zoom}px rgba(${DRAW.light}, 0.9))` }}
|
|
/>
|
|
)}
|
|
{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 (
|
|
<g key={col} opacity={opening ? 1 : others}>
|
|
<rect x={x - TILE / 2} y={y - TILE / 2} width={TILE} height={TILE} rx={TILE * 0.24} fill={DRAW.bg} />
|
|
<rect
|
|
x={x - TILE / 2}
|
|
y={y - TILE / 2}
|
|
width={TILE}
|
|
height={TILE}
|
|
rx={TILE * 0.24}
|
|
fill={`rgba(248, 250, 252, ${0.1 + lit * 0.22})`}
|
|
stroke={touched ? DRAW.mark : DRAW.fg}
|
|
strokeOpacity={touched ? 1 : 0.75 + lit * 0.25}
|
|
strokeWidth={touched ? hair * 1.6 : hair}
|
|
/>
|
|
</g>
|
|
);
|
|
})}
|
|
{pulseX !== null && folding > 0 && (
|
|
<circle
|
|
cx={pulseX}
|
|
cy={y}
|
|
r={radius * 0.8}
|
|
fill={DRAW.mark}
|
|
opacity={(hero ? 1 : others) * folding}
|
|
style={{ filter: `drop-shadow(0 0 ${10 / zoom}px ${DRAW.mark})` }}
|
|
/>
|
|
)}
|
|
{walkerShown > 0 && (
|
|
<g opacity={Math.min(1, walkerShown)}>
|
|
{[1, 2, 3, 4, 5].map((k) => {
|
|
const ghost = place(frame - k * 2.2);
|
|
return <circle key={k} cx={ghost.x} cy={ghost.y} r={radius * (1 - k * 0.13)} fill={DRAW.mark} opacity={0.3 - k * 0.05} />;
|
|
})}
|
|
<circle
|
|
cx={walker.x}
|
|
cy={walker.y}
|
|
r={radius}
|
|
fill={DRAW.mark}
|
|
style={{ filter: `drop-shadow(0 0 ${12 / zoom}px ${DRAW.mark})` }}
|
|
/>
|
|
</g>
|
|
)}
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
);
|
|
};
|
|
|
|
/** Automation, as one continuous shot: from ping-pong to tools that talk to each other. */
|
|
export const OrderFilm: FC = () => (
|
|
<FilmStage captions={CAPTIONS}>
|
|
<OrderDrawing />
|
|
</FilmStage>
|
|
);
|