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>
107 lines
4.5 KiB
TypeScript
107 lines
4.5 KiB
TypeScript
import type { FC } from "react";
|
|
import { useCurrentFrame } from "remotion";
|
|
import { COLOR, 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, a block wobbles on another: Jenga. The camera pulls back: it is a whole leaning tower,
|
|
* the app, swaying with every change. It stops, a light rises through it, the blocks line up one
|
|
* by one, and two rails keep it straight.
|
|
*/
|
|
const BLOCK = { width: 230, height: 44, pitch: 52, base: 290 } as const;
|
|
/** How far each block sits off centre while the tower is crooked. */
|
|
const OFFSETS = [0, 14, -10, 22, -18, 26, -8, 30, -22] as const;
|
|
const TOP = OFFSETS.length - 1;
|
|
const SWAY_FRAMES = 46;
|
|
const SWAY_DEGREES = 2.2;
|
|
const ALIGN_EVERY = 8;
|
|
const RAIL_GAP = 42;
|
|
|
|
const blockY = (index: number): number => BLOCK.base - BLOCK.height / 2 - index * BLOCK.pitch;
|
|
const alignStart = (index: number): number => T.resolve + index * ALIGN_EVERY;
|
|
const LAST_ALIGN = alignStart(TOP) + 22;
|
|
|
|
const Drawing: FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const camera = cameraAt(frame, 2.4, 1, { x: 0, y: blockY(TOP) + 30 });
|
|
const { zoom, hair, others } = camera;
|
|
const folded = frame >= T.fold;
|
|
/** How much the tower moves: it stops at the freeze and starts again for the loop. */
|
|
const restless = folded ? progress(frame, T.fold + 16, SHOT_FRAMES) : 1 - progress(frame, T.freeze, T.turn);
|
|
const wave = Math.sin((frame / SWAY_FRAMES) * Math.PI * 2);
|
|
const crooked = (index: number): number =>
|
|
folded ? inOut(progress(frame, T.fold, T.fold + 40)) : 1 - inOut(progress(frame, alignStart(index), alignStart(index) + 22));
|
|
const scan = progress(frame, T.turn, LAST_ALIGN);
|
|
const scanShown = Math.min(progress(frame, T.turn, T.turn + 10), 1 - progress(frame, LAST_ALIGN - 10, LAST_ALIGN));
|
|
const rails = out(progress(frame, T.calm, T.calm + 40)) * untilFold(frame);
|
|
const top = blockY(TOP) - BLOCK.height / 2 - 20;
|
|
|
|
return (
|
|
<g transform={camera.transform}>
|
|
<path d={`M-280 ${BLOCK.base}h560`} stroke={DRAW.fg} strokeOpacity={0.5 * others} strokeWidth={hair} strokeLinecap="round" />
|
|
|
|
{[-1, 1].map((side) => (
|
|
<path
|
|
key={side}
|
|
d={`M${side * (BLOCK.width / 2 + RAIL_GAP)} ${BLOCK.base}V${mix(BLOCK.base, top, rails)}`}
|
|
stroke={COLOR.done}
|
|
strokeWidth={hair * 1.6}
|
|
strokeLinecap="round"
|
|
opacity={rails > 0 ? 1 : 0}
|
|
style={glow(COLOR.done, 10, zoom)}
|
|
/>
|
|
))}
|
|
|
|
<g transform={`rotate(${wave * SWAY_DEGREES * restless} 0 ${BLOCK.base})`}>
|
|
{OFFSETS.map((offset, index) => {
|
|
const off = crooked(index);
|
|
// The top block wobbles on its own: that is the opening picture.
|
|
const wobble = index === TOP ? Math.sin((frame / (SWAY_FRAMES / 2)) * Math.PI * 2) * 3.5 * restless : 0;
|
|
const justAligned = folded ? 0 : Math.sin(Math.PI * progress(frame, alignStart(index), alignStart(index) + 30));
|
|
const y = blockY(index);
|
|
return (
|
|
<g
|
|
key={index}
|
|
opacity={index >= TOP - 1 ? 1 : others}
|
|
transform={`translate(${offset * off} 0) rotate(${wobble} ${-BLOCK.width / 2} ${y + BLOCK.height / 2})`}
|
|
>
|
|
<rect x={-BLOCK.width / 2} y={y - BLOCK.height / 2} width={BLOCK.width} height={BLOCK.height} rx={13} fill={DRAW.bg} />
|
|
<rect
|
|
x={-BLOCK.width / 2}
|
|
y={y - BLOCK.height / 2}
|
|
width={BLOCK.width}
|
|
height={BLOCK.height}
|
|
rx={13}
|
|
fill={`rgba(248, 250, 252, ${0.1 + justAligned * 0.25})`}
|
|
stroke={justAligned > 0.1 ? DRAW.mark : DRAW.fg}
|
|
strokeOpacity={SURFACE.strokeOpacity + justAligned * 0.25}
|
|
strokeWidth={hair * (1 + justAligned * 0.6)}
|
|
/>
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
|
|
{scanShown > 0 && (
|
|
<path
|
|
d={`M-190 ${mix(BLOCK.base, top, scan)}h380`}
|
|
stroke={DRAW.fg}
|
|
strokeWidth={hair * 1.4}
|
|
strokeLinecap="round"
|
|
opacity={scanShown}
|
|
style={glow(`rgba(${DRAW.light}, 1)`, 10, zoom)}
|
|
/>
|
|
)}
|
|
</g>
|
|
);
|
|
};
|
|
|
|
/** Making an app reliable: from a leaning tower that sways with every change to one that stands. */
|
|
export const AppFilm: FC = () => (
|
|
<FilmStage captions={CAPTIONS}>
|
|
<Drawing />
|
|
</FilmStage>
|
|
);
|