feat: video films for social media, with the skill to make more

Adds apps/video, a standalone Remotion package (not part of the pnpm
workspace, so it never reaches the web build or the Docker image). It
renders six looping films, one per service, in German and English, as
4:5 for LinkedIn and 16:9 for YouTube and ads, together with the text
to post and an overview page.

Each film is one continuous shot: close on something abstract, the
camera pulls back to show what it really is, the form changes, and the
picture folds back into its opening. The slide deck code from the
earlier attempts is still in the package and no longer used.

Also adds the Google Ads concept, brings the relaunch concept in line
with the site as built, and a project skill that describes how to make
further films.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 21:23:01 +02:00
co-authored by Claude Opus 5.5
parent 9ed436330b
commit 4bfbcbcacd
53 changed files with 7649 additions and 13 deletions
+195
View File
@@ -0,0 +1,195 @@
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);
};
const Drawing: 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}>
<Drawing />
</FilmStage>
);