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:
@@ -0,0 +1,128 @@
|
||||
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 };
|
||||
};
|
||||
|
||||
const Drawing: 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}>
|
||||
<Drawing />
|
||||
</FilmStage>
|
||||
);
|
||||
Reference in New Issue
Block a user