Files
mintel.me/apps/video/src/films/DocumentsFilm.tsx
T
mmintelandClaude Opus 5.5 5cbcb2eac4 feat: content strategy, image posts and a posting plan for the films
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>
2026-10-05 10:00:28 +02:00

130 lines
5.4 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 { FilmStage } from "./FilmStage";
import { CAPTIONS, SHOT_FRAMES, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
/**
* Close up, bars drop onto a stack: a skyscraper going up. The camera pulls back: it is the pile of
* invoices waiting to be typed in, and it keeps growing. Then a light runs down the pile and the
* sheets leave by themselves: most into the books, the few unclear ones to a person.
*/
const BAR = { x: -170, width: 210, height: 16, pitch: 25, base: 290 } as const;
const MAX_BARS = 24;
const BOOKS = { x: 250, y: -70, size: 130 } as const;
const PERSON = { x: 250, y: 170, r: 44 } as const;
const OPENING_BARS = 3;
const CLOSE_EVERY = 25;
/** The film starts with a bar in mid-fall: the first frame is the preview image. */
const CLOSE_PHASE = 12;
const WIDE_EVERY = 9;
const LEAVE_EVERY = 5;
const LEAVE_FRAMES = 24;
/** Every sixth sheet is unclear and goes to a person. */
const isUnclear = (index: number): boolean => index % 6 === 2;
const barY = (index: number): number => BAR.base - BAR.height / 2 - index * BAR.pitch;
/** How many bars have arrived, as a number with a fraction: the fraction is the one still falling. */
const barsAt = (frame: number): number => {
if (frame >= T.fold) return OPENING_BARS + (frame - SHOT_FRAMES + CLOSE_PHASE) / CLOSE_EVERY;
const close = OPENING_BARS + (Math.min(frame, T.reveal) + CLOSE_PHASE) / CLOSE_EVERY;
return Math.min(MAX_BARS, close + Math.max(0, frame - T.reveal) / WIDE_EVERY);
};
const leaveStart = (index: number): number => T.resolve + (MAX_BARS - 1 - index) * LEAVE_EVERY;
const LAST_LEAVE = T.resolve + MAX_BARS * LEAVE_EVERY;
const UNCLEAR = Array.from({ length: MAX_BARS }, (_, index) => index).filter(isUnclear);
/** Where an unclear sheet waits next to the person. */
const waitingSpot = (index: number): { x: number; y: number } => {
const angle = UNCLEAR.indexOf(index) * 0.62 + 2.2;
return { x: PERSON.x + Math.cos(angle) * 78, y: PERSON.y + Math.sin(angle) * 78 };
};
/** The picture alone, without stage and statements: also used frozen, as an image post. */
export const DocumentsDrawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 2.6, 0.95, { x: BAR.x, y: barY(OPENING_BARS) - 20 });
const { zoom, hair, others } = camera;
const bars = barsAt(frame);
const leaving = frame < T.fold;
const scan = progress(frame, T.turn, LAST_LEAVE);
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_LEAVE - 10, LAST_LEAVE));
const dot = 11 / Math.sqrt(zoom);
return (
<g transform={camera.transform}>
<path d={`M${BAR.x - 170} ${BAR.base}h340`} stroke={DRAW.fg} strokeOpacity={0.5} strokeWidth={hair} strokeLinecap="round" />
<g opacity={others}>
<rect
x={BOOKS.x - BOOKS.size / 2}
y={BOOKS.y - BOOKS.size / 2}
width={BOOKS.size}
height={BOOKS.size}
rx={BOOKS.size * 0.24}
fill={SURFACE.fill}
stroke={DRAW.fg}
strokeOpacity={SURFACE.strokeOpacity}
strokeWidth={hair}
/>
<circle cx={PERSON.x} cy={PERSON.y} r={PERSON.r} fill={SURFACE.fill} stroke={DRAW.fg} strokeOpacity={SURFACE.strokeOpacity} strokeWidth={hair} />
</g>
{Array.from({ length: MAX_BARS }, (_, index) => {
if (index >= bars) return null;
const arrived = Math.min(1, bars - index);
const fadeIn = leaving ? 1 : progress(frame, T.fold + 8, T.fold + 30);
const gone = leaving ? inOut(progress(frame, leaveStart(index), leaveStart(index) + LEAVE_FRAMES)) : 0;
const unclear = isUnclear(index);
const target = unclear ? waitingSpot(index) : BOOKS;
const width = mix(BAR.width, dot * 2, gone);
const height = mix(BAR.height, dot * 2, gone);
const x = mix(BAR.x, target.x, gone);
const y = mix(mix(barY(index) - 150 / zoom, barY(index), out(arrived)), target.y, gone) - Math.sin(Math.PI * gone) * 70;
const opacity = arrived * fadeIn * (unclear ? 1 : 1 - progress(gone, 0.82, 1));
if (opacity === 0) return null;
return (
<rect
key={index}
x={x - width / 2}
y={y - height / 2}
width={width}
height={height}
rx={height / 2}
fill={unclear && gone > 0 ? DRAW.mark : DRAW.fg}
opacity={opacity * (gone > 0 ? 1 : 0.9)}
style={unclear && gone > 0.5 ? glow(DRAW.mark, 10, zoom) : undefined}
/>
);
})}
{!leaving &&
UNCLEAR.map((index) => {
const spot = waitingSpot(index);
return <circle key={index} cx={spot.x} cy={spot.y} r={dot} fill={DRAW.mark} opacity={untilFold(frame) * others} />;
})}
{scanShown > 0 && (
<path
d={`M${BAR.x - 150} ${mix(barY(MAX_BARS - 1), BAR.base, scan)}h300`}
stroke={DRAW.fg}
strokeWidth={hair * 1.4}
strokeLinecap="round"
opacity={scanShown}
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
/>
)}
</g>
);
};
/** Incoming invoices: from a growing pile to documents that are read and booked by themselves. */
export const DocumentsFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<DocumentsDrawing />
</FilmStage>
);