Merge feat/video-playground: social media films, ads concept and video skill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 21:23:32 +02:00
co-authored by Claude Opus 5.5
53 changed files with 7649 additions and 13 deletions
+95
View File
@@ -0,0 +1,95 @@
---
name: video-film
description: Make or change a social media film in apps/video (Remotion) in the approved mintel.me style - one continuous shot, minimal, with a camera pull-back as the reveal, in German and English. Use when asked for a new video, a new topic, changes to an existing film, new formats, or to re-render the video library.
---
# Making a film for mintel.me
The videos in `apps/video` are short looping films for LinkedIn and ads. The owner approved this
style after rejecting several others. Read `apps/video/CLAUDE.md` first; this skill is the procedure.
## What the owner rejected, so do not go back to it
- Slides with a panel, a progress bar, labels and a frame around everything.
- Much text, small text, text sizes that change from line to line.
- Hectic motion: hops of a third of a second, six statements in 15 seconds.
- Busy drawings: icons, labels, counters, stacks, several effects at once.
- A tiny logo in a black box.
- German that reads translated.
## The style
One continuous shot on a dark stage. Only squares, dots, lines and bars. One statement at a time,
two short lines, large and centred under the drawing. Calm: nothing moves faster than it has to, every
statement stays for about three seconds.
Every film tells the same kind of story in six beats, on the shared timeline `T` in
`src/films/shot.ts` (690 frames at 30 fps, 23 seconds):
| Beat | Caption id | What happens |
| :--- | :--------- | :----------- |
| Hook | `hook` | Close on something small and abstract that looks like something else. "Looks like ping-pong." |
| Reveal | `reveal` | The camera pulls back and shows what it really is. "It's your office." This is the aha moment. |
| Problem | `problem` | The whole picture keeps going, and it is clearly too much. |
| Turn | `turn` | Everything slows to a halt, then the form changes: arcs become a line, a pile leaves by itself. |
| Calm | `calm` | The result at rest, with one quiet movement. |
| Call to action | `cta` | A question that picks up the hook ("Done with ping-pong?") and the website's button. |
Then the picture folds back into its opening, so the last frame equals the first and the film loops.
Frame 0 is the preview image: it must already look good and carry the hook line.
## Procedure
1. **Bring the worktree up to date with `main`** and read the service in
`apps/web/src/content/de.ts` and `en.ts`. Claims, prices and terms come from there and nowhere else.
2. **Find the picture before writing code.** Answer in one line each:
- What does the viewer see close up, and what harmless thing does it look like?
- What does the camera reveal when it pulls back?
- Which single change of form resolves it?
If the reveal does not make you think "oh", look for another picture. Existing ones:
ping-pong / office, skyscraper / pile of invoices, star / unanswered enquiries,
blinking cursor / empty website, Jenga / fragile app, loading bar / backlog.
3. **Write the six statements** in `src/content/videos.ts`, German and English, plus the post text.
Two lines each, as short as possible: one font size is used for the whole film, set by the longest
line. The hook is "Sieht aus wie ..." / "Looks like ...". A little sarcasm is welcome. German uses
"Sie" and has to sound like a native wrote it; write it first, do not translate it from English.
The post text opens with the hook. Copy rules of the root `CLAUDE.md` apply.
4. **Write the film** as `src/films/<Name>Film.tsx`. Copy the structure of `AppFilm.tsx` (simplest)
or `OrderFilm.tsx` (several moving things):
- `cameraAt(frame, zoomIn, zoomOut, focus)` gives `transform`, `zoom`, `hair` and `others`.
Everything that is not part of the opening picture gets `opacity={others}`.
- Draw in stage coordinates, (0, 0) in the centre, 1000 x 700. Use `hair` for stroke widths and
divide glow sizes by `zoom`, so lines look the same at every zoom.
- All motion is a pure function of the frame: `progress`, `inOut`, `out`, `mix` from
`src/illustrations/motion.ts`. No timers, no randomness, no CSS animations.
- For the loop: after `T.fold` the opening picture has to be rebuilt exactly. Anything periodic
needs a period that divides 690 (23, 30, 46). Compare frame 0 and frame 689.
- Logic worth testing goes into `src/lib` with a spec, written first (see `hop.ts`).
5. **Register it**: `src/compositions/<VideoId>.tsx` exporting a `VideoEntry` with `Film`, and the
list in `src/Root.tsx`. A new id also goes into `VIDEO_IDS` in `videos.ts`.
6. **Check**: `pnpm typecheck` and `pnpm test` in `apps/video`.
7. **Render and look**: `node scripts/export.ts --only <VideoId>`, then pull frames from the MP4
(start, middle of the pull-back, problem, turn, calm, call to action, last frame) in `feed` and
`wide`, and read them. Check: nothing overlaps the text, the drawing fades at the stage edges
during the camera move, frame 689 equals frame 0, German lines fit.
8. **Report** what was rendered, what you looked at, and what you did not. You cannot play video:
say so, and leave the judgement of pace and feel to the owner.
## Traps that have cost time here
- **A render can silently not happen.** Redirect logs with `>|`, not `>` (the shell refuses to
overwrite an existing file and the command never starts). After every export, compare the file times
with the clock before sending anything. Run one export at a time.
- **Glows**: `drop-shadow` on SVG shapes is fine. Blur filters under a CSS mask, blend modes and dashed
strokes came out with hard edges; build soft light from gradients that fade to transparent.
- **The stage is not clipped by itself.** While the camera is close, the drawing is larger than the
stage; `FilmStage` fades it at the edges. Do not rely on things being "off screen".
- **Do not name a helper script `copy.py`, `json.py` or similar**: it shadows the Python module.
- **Text from an old worktree is stale.** The website changed language and wording during this work.
## Formats and output
Compositions are `<VideoId>-<de|en>-<feed|wide|square|story>`. `node scripts/export.ts` renders
`feed` (4:5, LinkedIn) and `wide` (16:9, YouTube and ads) in both languages into
`out/library/<VideoId>/<lang>/`, with `post.txt` and an overview page `out/library/index.html`.
`out/` is not committed.
+4 -3
View File
@@ -1,7 +1,7 @@
# mintel.me
Personal website of a senior freelance engineer. Next.js 16 (App Router), pnpm monorepo.
App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.md` (read it first).
App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, standalone, own `CLAUDE.md`). Concept and scope: `apps/web/plans/freelance-relaunch.md` (read it first).
## Commands (run from repo root)
@@ -54,8 +54,9 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
- Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits),
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition). Reuse them
instead of inventing new effects.
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (light
along the edge of an element, digits drifting off it), `BitSeam` (lit top edge of a dark section), `BitGlow`
(light following the pointer on `data-glow` surfaces). Reuse them instead of inventing new effects.
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
+3
View File
@@ -0,0 +1,3 @@
node_modules
out
.remotion
+89
View File
@@ -0,0 +1,89 @@
# apps/video
Social media videos (LinkedIn first, 16:9 for YouTube and Google Ads) in the mintel.me look, made with Remotion.
Standalone package: it is **not** in `pnpm-workspace.yaml`, so it never ends up in the web build, the Dockerfile
or `pnpm -r`. `apps/web` must never import from here, and this package imports nothing from `apps/web`.
## Commands (run from this folder)
- Install: `pnpm install --ignore-workspace`
- Studio (the playground, http://localhost:3000): `pnpm studio`
- One frame as PNG: `pnpm still WebsiteDone-de-feed out/check.png --frame=40`
- One video: `pnpm render WebsiteDone-de-feed out/website.mp4`
- The whole library: `pnpm library` (= `node scripts/export.ts`; `--only <VideoId>`, `--formats feed,wide`,
`--page` to rewrite only post texts and the overview). Writes `out/library/index.html` (watch, copy the post
text, download) and `out/library/<video>/<language>/` with the MP4s and `post.txt`.
- `pnpm typecheck`, `pnpm test`
Compositions are named `<VideoId>-<language>-<format>`: languages `en`, `de`; formats `feed` (4:5, LinkedIn),
`wide` (16:9), `square`, `story` (9:16). `out/` is gitignored.
## Where things are
- `src/content/videos.ts`: every word. Headlines per slide, the post text per video, both languages, and the
German for labels inside drawings (`LABELS_DE`). Drawing code writes labels in English; they are translated
when rendered. German addresses the viewer as "Sie".
- `src/compositions/<Video>.tsx`: the slides of one video (ids, durations, illustrations). No text.
- `src/slides`: `SlideDeck` (panel, headline, curtain, loop, portrait and 16:9 layout), `ServiceVideo`
(frame + language + deck), `ctaSlide` (closing slide with the button).
- `src/illustrations`: `parts.tsx` (tile, icon, cursor, data chip, window, page, badges, day box), `scenes.tsx`
(scenes several videos share), `motion.ts` (easing helpers).
- `src/brand`: tokens, fonts, `BrandFrame` (logo, labels, digit texture), `Panel` (the dark panel with the
website's light effects), `BrandButton`.
- `src/lib`: pure, tested logic (`bits.ts`, `aura.ts`).
## How to make a new video
1. Add the texts to `src/content/videos.ts` (new `VideoId`, both languages, post text).
2. Add `src/compositions/<Name>.tsx` with its slides and register it in `src/Root.tsx`.
3. Look before reporting: render the MP4, pull one frame per slide and read them, in `feed` and `wide`, in both
languages (German lines are longer). Do not hand over a video you have not seen.
4. `pnpm library --only <Name>` and tell the owner where the files are.
## Films (the current format, owner approved 2026-10-04)
Every video is a film in `src/films`: one continuous shot on a dark stage, not a deck of slides.
- Shared: `FilmStage` (backdrop, one centred statement at a time, signature, address) and `shot.ts`
(one timeline `T`, the camera move `cameraAt`, the six caption slots hook/reveal/problem/turn/calm/cta).
- The pattern: close on something abstract that looks like something else ("Looks like ping-pong."), the
camera pulls back and shows what it really is ("It's your office."), it stops, resolves, rests, and
returns to the opening picture so the film loops. Frame 0 is the preview image.
- Minimal and calm (owner feedback: earlier versions were hectic). Only squares, dots, lines and bars; no
icons or labels in the drawing. One statement of two short lines, held for about three seconds.
A little sarcasm in the hook is welcome. German has to read like a native wrote it.
- One font size per film: the largest at which the longest line fits, so keep lines short.
- The logo is the white signature (`public/logo-mark.svg`): bottom left, and very large and faint behind.
- The slide deck code (`src/slides/SlideDeck`, `src/illustrations`, `BrandFrame`, `Panel`) is no longer used
by any video and can be removed once the owner confirms.
## Rules
- Before any work: bring the worktree up to date with `main` (owner feedback, twice). Take wording, styles and
effects from the website as it is **now**: texts in `apps/web/src/content/de.ts` and `en.ts`, styles in
`apps/web/app/globals.css`, drawings in `apps/web/src/components/illustrations`. Do not state prices, terms
or durations that are not in the texts.
- Picture first, few words (owner feedback): 6 to 8 slides of about 2 s, each one large animated illustration
plus a headline of two to four words. No paragraphs, no captions.
- It has to look stunning (owner feedback, repeated: "not enough yet"). The videos carry every effect of the
website: the dark `Panel` with `BitAura` (light along the edge, digits drifting off), `BitGlow` (light over
the dot grid) and `bit-aurora` (indigo light drifting behind the drawing); slides change with the `BitCurtain`
(ragged front of digits, `src/lib/curtain.ts`); the button has the `bit-cta` light running round its edge.
When the website gets a new effect, bring it here as a pure function of the frame in `src/lib`.
- Drawings follow `PathScenes.tsx`: fine light lines, tinted glass surfaces (`DRAW.tint`, `DRAW.glass`) instead
of solid blocks, amber packets with a halo (`DRAW.mark`), emerald for done, soft glows on what is active.
- UI in a video must be the website's real UI, rebuilt faithfully (owner feedback): read the component in
`apps/web/src/components` first. The button is `BrandButton` (twin of `Button.tsx`); do not draw your own.
- Almost no frame (owner feedback: the drawing matters far more than what surrounds it). No border, no name,
no topic label, no progress bar. Only one small line at the bottom: the logo, small and dimmed, and
`www.mintel.me` (with `www.` so that it reads as an address). The stage is scaled as large as the frame allows.
- Light is drawn with plain gradients that fade to transparent. No `filter: blur`, `mix-blend-mode` or dashed
strokes for glows: they came out with hard edges in the rendered video (owner feedback).
- Every video loops: the first slide is complete on frame 0 (it is the preview image) and fades back in at the
end. Anything that moves outside the slides (`Panel`) must repeat exactly over the length of the video.
- Frame-driven only: all motion comes from `useCurrentFrame()` / `interpolate()`. No `setTimeout`,
`setInterval`, CSS animations, `Math.random()` or framer-motion: they do not render correctly frame by frame.
Pure logic goes in `src/lib` with a `*.spec.ts`.
- 30 fps, about 15 seconds. The video must work without sound and make sense in the first two seconds.
- Copy rules as on the website (see root `CLAUDE.md`): no AI as the owner's tool, no internal method vocabulary,
modest and factual, no promises the owner did not confirm. Never write the full email address.
- The hero design of the website (binary-digit headline) is approved: do not recreate it here with changes.
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@mintel/video",
"private": true,
"type": "module",
"description": "Social media videos in the mintel.me look. Standalone: not part of the pnpm workspace, never imported by apps/web.",
"scripts": {
"studio": "remotion studio src/index.ts",
"render": "remotion render src/index.ts",
"still": "remotion still src/index.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"library": "node scripts/export.ts"
},
"dependencies": {
"@remotion/cli": "4.0.532",
"@remotion/google-fonts": "4.0.532",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"remotion": "4.0.532"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.2.0",
"typescript": "5.9.3",
"vitest": "~4.1.11"
}
}
+3632
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 745 744" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Favicon" x="0.855" y="0.277" width="743.633" height="743.633" style="fill:none;"/><path d="M739.657,49.961c-0,-24.755 -20.098,-44.852 -44.853,-44.852l-644.264,-0c-24.755,-0 -44.853,20.097 -44.853,44.852l-0,644.265c-0,24.755 20.098,44.853 44.853,44.853l644.264,-0c24.755,-0 44.853,-20.098 44.853,-44.853l-0,-644.265Z"/><path d="M135.778,286.282c0.157,0.236 0.52,0.761 0.852,1.092c1.2,1.192 2.956,2.358 5.296,3.587l0.005,0.003l2,1.327l1.693,1.752l1.239,2.072l0.713,2.215l0.211,2.192l-0.208,2.068l-1.385,3.646l-2.46,2.971l-1.634,1.187l-1.911,0.914l-2.161,0.551l-2.323,0.093l-2.343,-0.412l-2.211,-0.892c-11.589,-6.087 -16.814,-12.713 -18.39,-18.621c-1.835,-6.879 0.575,-13.751 6.509,-19.965c12.824,-13.43 44.389,-24.257 57.136,-27.454l0.001,-0c30.563,-7.658 62.165,-12.34 93.648,-13.693c25.54,-1.098 54.423,-1.803 78.831,7.271c15.51,5.768 26.031,14.726 32.759,25.559c56.484,-46.707 118.893,-93.116 187.414,-119.225c10.958,-4.178 27.246,-8.604 40.339,-7.415c7.908,0.718 14.744,3.442 19.461,8.573c4.662,5.072 7.541,12.672 6.521,23.945c-3.634,40.091 -31.543,80.109 -52.575,112.911c-25.848,40.321 -53.663,79.557 -82.723,117.821c29.191,-23.305 58.382,-46.61 87.573,-69.913l1.731,-1.145l1.85,-0.796l3.75,-0.585l3.526,0.562l3.17,1.552l2.672,2.595l1.028,1.728l0.705,1.995l0.285,2.178l-0.185,2.22l-0.631,2.115l-0.992,1.91c-10.505,16.533 -21.014,33.063 -31.523,49.592l-0.001,0.003c-1.852,2.909 -11.995,19.195 -18.14,30.842c-0.096,0.182 -0.192,0.366 -0.288,0.553c13.673,-3.721 27.615,-13.517 38.776,-19.936c10.441,-6.004 20.778,-12.208 30.865,-18.787l0.003,-0.002l2.134,-1.1l2.356,-0.626l2.421,-0.072l2.289,0.463l2.022,0.893l1.703,1.201l2.495,3.003l1.373,3.598l0.232,1.999l-0.139,2.106l-0.573,2.146l-1.048,2.067l-1.498,1.843l-1.854,1.496l-0.007,0.005c-15.715,10.242 -31.914,19.975 -48.604,28.526c-6.986,3.579 -18.808,10.744 -29.918,13.789c-9.41,2.579 -18.37,2.143 -24.958,-2.988c-5.648,-4.398 -7.104,-11.077 -5.042,-18.895c3.104,-11.773 15.551,-27.001 19.276,-32.858l2.604,-4.095c-37.274,29.759 -74.551,59.518 -111.826,89.274l-1.82,1.214l-2.004,0.868l-2.111,0.471l-2.12,0.061l-2.039,-0.329l-1.892,-0.676l-3.201,-2.224l-2.259,-3.202l-0.689,-1.912l-0.33,-2.068l0.077,-2.148l0.5,-2.129l0.902,-2.005l1.242,-1.804c59.233,-71.996 118.215,-147.452 163.946,-228.856l0.002,-0.003c3.532,-6.277 19.498,-32.912 25.637,-54.458c1.456,-5.11 2.365,-9.885 2.213,-13.918c-0.128,-3.403 -1.052,-6.169 -4.397,-6.847c-6.883,-1.395 -14.409,-0.182 -21.911,2.075c-12.591,3.787 -25.072,10.598 -34.871,15.502l-0.002,0.001c-37.202,18.606 -72.519,40.846 -106.083,65.396c-19.253,14.077 -38.067,28.773 -56.448,44.009c5.948,31.922 -8.725,71.663 -25.261,97.617c-26.624,41.789 -61.114,78.396 -97.084,112.241c-35.155,33.081 -71.676,66.504 -111.219,94.355l-0.002,0.001c-4.905,3.453 -13.056,9.944 -21.245,13.763c-7.008,3.268 -14.124,4.488 -20.021,2.432c-7.666,-2.674 -10.711,-8.481 -11.105,-15.23c-0.288,-4.928 1.117,-10.472 2.745,-14.802l0.001,-0.003c17.424,-46.26 54.722,-88.018 86.091,-125.21c52.005,-61.657 108.894,-119.681 170.402,-171.929c-5.142,-9.861 -13.608,-17.675 -25.833,-20.957c-27.596,-7.404 -57.826,-6.098 -86.019,-3.428c-30.452,2.883 -61.745,7.625 -90.667,17.984c-6.667,2.388 -17.118,6.215 -22.892,11.134c-0.89,0.758 -1.884,2.115 -2.149,2.485Zm-6.046,295.699c3.853,-1.713 7.757,-4.116 11.623,-6.805c12.067,-8.393 23.538,-19.805 31.614,-26.433c45.641,-37.472 90.707,-76.66 129.793,-121.083c26.644,-30.283 57.988,-66.814 65.641,-107.833c1.02,-5.466 1.414,-11.09 1.137,-16.634c-41.419,35.852 -80.575,74.39 -117.54,114.67c-41.853,45.61 -85.416,93.945 -115.619,148.393c-0.671,1.213 -4.049,9.375 -6.649,15.725Z" style="fill:#fff;"/></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+7
View File
@@ -0,0 +1,7 @@
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setCodec("h264");
Config.setPixelFormat("yuv420p");
Config.setCrf(16);
Config.setOverwriteOutput(true);
+137
View File
@@ -0,0 +1,137 @@
/**
* Renders the video library: every video in every language, with the text to post next to it,
* and an overview page to watch, copy and download from.
*
* node scripts/export.ts everything
* node scripts/export.ts --only WebsiteDone one video
* node scripts/export.ts --formats feed one format (feed, wide, square, story)
* node scripts/export.ts --page only rewrite post texts and the overview page
*
* Output: out/library/index.html and out/library/<video>/<language>/.
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { COPY, LANGS, VIDEO_IDS, type Lang, type VideoId } from "../src/content/videos.ts";
const LIBRARY = join("out", "library");
const DEFAULT_FORMATS = ["feed", "wide"];
const FORMAT_NOTE: Readonly<Record<string, string>> = {
feed: "4:5, LinkedIn feed",
wide: "16:9, YouTube and Google Ads",
square: "1:1",
story: "9:16, vertical",
};
const option = (name: string): string | undefined => {
const index = process.argv.indexOf(`--${name}`);
return index === -1 ? undefined : process.argv[index + 1];
};
const only = option("only");
const formats = option("formats")?.split(",") ?? DEFAULT_FORMATS;
const pageOnly = process.argv.includes("--page");
const videoIds = VIDEO_IDS.filter((id) => only === undefined || id === only);
if (videoIds.length === 0) throw new Error(`Unknown video "${only}". Known: ${VIDEO_IDS.join(", ")}`);
const fileName = (id: VideoId, lang: Lang, format: string): string => `${id}-${lang}-${format}.mp4`;
const folder = (id: VideoId, lang: Lang): string => join(LIBRARY, id, lang);
for (const id of videoIds) {
for (const lang of LANGS) {
mkdirSync(folder(id, lang), { recursive: true });
writeFileSync(join(folder(id, lang), "post.txt"), `${COPY[id][lang].post}\n`);
if (pageOnly) continue;
for (const format of formats) {
const target = join(folder(id, lang), fileName(id, lang, format));
console.log(`Rendering ${target}`);
const startedAt = Date.now();
execFileSync("pnpm", ["exec", "remotion", "render", "src/index.ts", `${id}-${lang}-${format}`, target, "--log=error"], {
stdio: "inherit",
});
// A render can end without an error and without a new file (seen with two renders started at once).
if (!existsSync(target) || statSync(target).mtimeMs < startedAt) {
throw new Error(`${target} was not written. Run one export at a time.`);
}
}
}
}
const escapeHtml = (text: string): string =>
text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const card = (id: VideoId, lang: Lang): string => {
const copy = COPY[id][lang];
const videos = Object.keys(FORMAT_NOTE)
.filter((format) => existsSync(join(folder(id, lang), fileName(id, lang, format))))
.map((format) => {
const src = `${id}/${lang}/${fileName(id, lang, format)}`;
return `<figure class="${format}">
<video src="${src}" controls loop muted playsinline preload="metadata"></video>
<figcaption>${FORMAT_NOTE[format]} · <a href="${src}" download>Download</a></figcaption>
</figure>`;
})
.join("\n");
return `<article>
<header><span class="lang">${lang.toUpperCase()}</span><h2>${escapeHtml(copy.title)}</h2></header>
<div class="videos">${videos || "<p>Not rendered yet.</p>"}</div>
<div class="post">
<div class="post-head"><span>Post text</span><button type="button">Copy</button></div>
<textarea readonly rows="14">${escapeHtml(copy.post)}</textarea>
</div>
</article>`;
};
const page = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Video library</title>
<style>
:root { --ink: #0f172a; --muted: #64748b; --line: #e2e8f0; --wash: #f8fafc; }
* { box-sizing: border-box; }
body { margin: 0; background: #fff; color: var(--ink); font-family: Inter, system-ui, sans-serif; }
main { max-width: 1400px; margin: 0 auto; padding: 48px 20px 96px; }
h1 { font-size: clamp(40px, 7vw, 88px); font-weight: 900; letter-spacing: -0.05em; line-height: 0.95; margin: 0 0 12px; }
.intro { color: var(--muted); font-size: 18px; max-width: 60ch; margin: 0 0 48px; }
article { border-top: 1px solid var(--line); padding: 36px 0; }
header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 20px; }
h2 { font-size: 26px; letter-spacing: -0.02em; margin: 0; }
.lang, figcaption, .post-head { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
.videos { display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start; }
figure { margin: 0; }
video { display: block; width: 100%; border: 1px solid var(--line); border-radius: 16px; background: var(--wash); }
figure.feed, figure.square { width: min(100%, 340px); }
figure.story { width: min(100%, 260px); }
figure.wide { width: min(100%, 620px); }
figcaption { margin-top: 10px; }
a { color: var(--ink); }
.post { margin-top: 24px; max-width: 760px; }
.post-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
textarea { width: 100%; border: 1px solid var(--line); border-radius: 16px; padding: 16px; font: 15px/1.5 Inter, system-ui, sans-serif; color: var(--ink); background: var(--wash); resize: vertical; }
button { border: 0; border-radius: 999px; background: var(--ink); color: #fff; padding: 10px 20px; font: 700 11px Inter, system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
</style>
</head>
<body>
<main>
<h1>Video library</h1>
<p class="intro">Every video in English and German, with the text to post alongside it. Rendered by <code>node scripts/export.ts</code>.</p>
${VIDEO_IDS.flatMap((id) => LANGS.map((lang) => card(id, lang))).join("\n")}
</main>
<script>
for (const button of document.querySelectorAll(".post button")) {
button.addEventListener("click", async () => {
const text = button.closest(".post").querySelector("textarea").value;
await navigator.clipboard.writeText(text);
button.textContent = "Copied";
setTimeout(() => { button.textContent = "Copy"; }, 1500);
});
}
</script>
</body>
</html>
`;
writeFileSync(join(LIBRARY, "index.html"), page);
console.log(`Overview: ${join(LIBRARY, "index.html")}`);
+43
View File
@@ -0,0 +1,43 @@
import type { FC } from "react";
import { Composition } from "remotion";
import { FORMATS, FPS, type FormatName } from "./brand/tokens";
import { appRescue } from "./compositions/AppRescue";
import { automationDocuments } from "./compositions/AutomationDocuments";
import { automationEnquiry } from "./compositions/AutomationEnquiry";
import { automationOrder } from "./compositions/AutomationOrder";
import { productDeveloper } from "./compositions/ProductDeveloper";
import { websiteDone } from "./compositions/WebsiteDone";
import { LANGS } from "./content/videos";
import { ServiceVideo, videoDuration, type VideoEntry } from "./slides/ServiceVideo";
/** Register new videos here. Each one is offered as `<id>-<language>-<format>`. */
const VIDEOS: readonly VideoEntry[] = [
automationOrder,
automationDocuments,
automationEnquiry,
websiteDone,
appRescue,
productDeveloper,
];
const FORMAT_NAMES = Object.keys(FORMATS) as FormatName[];
export const Root: FC = () => (
<>
{VIDEOS.flatMap((video) =>
LANGS.flatMap((lang) =>
FORMAT_NAMES.map((format) => (
<Composition
key={`${video.id}-${lang}-${format}`}
id={`${video.id}-${lang}-${format}`}
component={() => <ServiceVideo video={video} lang={lang} />}
durationInFrames={videoDuration(video)}
fps={FPS}
width={FORMATS[format].width}
height={FORMATS[format].height}
/>
)),
),
)}
</>
);
+70
View File
@@ -0,0 +1,70 @@
import type { FC, ReactNode } from "react";
import { Easing, interpolate, useCurrentFrame } from "remotion";
import { FONT } from "../brand/fonts";
import { COLOR } from "../brand/tokens";
import { bitString } from "../lib/bits";
const COLUMN_DIGITS = 48;
const SCRAMBLE_EVERY_FRAMES = 2;
const EDGE_PCT = 2;
/**
* Content revealed left to right behind a narrow column of binary digits (video twin of
* BitWipe on the site). The content keeps its place, so nothing reflows.
*/
export const BitWipe: FC<{
children: ReactNode;
/** Frame (inside the current sequence) where the wipe starts. */
from?: number;
durationInFrames?: number;
/** Size of the digit column in px. */
digitSize?: number;
tone?: "dark" | "light";
}> = ({ children, from = 0, durationInFrames = 24, digitSize = 22, tone = "dark" }) => {
const frame = useCurrentFrame();
const t = interpolate(frame, [from, from + durationInFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.bezier(0.65, 0, 0.35, 1),
});
const done = t >= 1;
const rightInset = 100 - t * (100 + EDGE_PCT);
const columnOpacity = interpolate(t, [0, 0.08, 0.86, 1], [0, 1, 1, 0]);
return (
<div style={{ position: "relative", display: "inline-block", whiteSpace: "nowrap" }}>
<div
style={{
clipPath: done
? "none"
: `inset(-12% ${Math.max(0, rightInset)}% -12% -2%)`,
}}
>
{children}
</div>
{t > 0 && !done && (
<span
aria-hidden="true"
style={{
position: "absolute",
top: 0,
bottom: 0,
left: `${t * 100}%`,
width: digitSize * 1.1,
overflow: "hidden",
opacity: columnOpacity,
fontFamily: FONT.mono,
fontWeight: 700,
fontSize: digitSize,
lineHeight: 1.05,
writingMode: "vertical-rl",
wordBreak: "break-all",
color: tone === "light" ? COLOR.paper : COLOR.ink,
}}
>
{bitString(COLUMN_DIGITS, Math.floor(frame / SCRAMBLE_EVERY_FRAMES))}
</span>
)}
</div>
);
};
+39
View File
@@ -0,0 +1,39 @@
import type { CSSProperties, FC } from "react";
import { Easing, interpolate, useCurrentFrame } from "remotion";
import { FONT } from "../brand/fonts";
import { COLOR } from "../brand/tokens";
import { decodeText } from "../lib/bits";
const SCRAMBLE_EVERY_FRAMES = 2;
/** Mono label resolving from digits (video twin of DecodeText on the site). Monospaced only. */
export const DecodeLabel: FC<{
text: string;
/** Frame (inside the current sequence) where decoding starts. */
from?: number;
durationInFrames?: number;
style?: CSSProperties;
}> = ({ text, from = 0, durationInFrames = 18, style }) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [from, from + durationInFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
});
const tick = Math.floor(frame / SCRAMBLE_EVERY_FRAMES);
return (
<span
style={{
fontFamily: FONT.mono,
fontWeight: 500,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: COLOR.muted,
whiteSpace: "pre",
...style,
}}
>
{decodeText(text, progress, tick)}
</span>
);
};
+102
View File
@@ -0,0 +1,102 @@
import type { CSSProperties, FC } from "react";
import { useCurrentFrame } from "remotion";
import { useT } from "../content/CopyContext";
import { FONT } from "./fonts";
import { COLOR, DRAW } from "./tokens";
/** One lap of the light round the edge, as on the website (6 s at 30 fps). */
const LAP_FRAMES = 180;
const SWEEP_FRAMES = 60;
/** Shows only a ring of the given thickness along the edge of its box. */
const ring = (thickness: number): CSSProperties => ({
padding: thickness,
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
});
/**
* The primary button of the website (apps/web Button.tsx with `bit-cta`): dark pill, bold uppercase
* label, the arrow in a circle, a light travelling round the edge with a soft glow behind it, and
* the spot of light that follows the pointer there. Here that spot sweeps across on its own.
*/
export const BrandButton: FC<{
label: string;
/** Font size in px; padding, arrow and light scale with it. */
size?: number;
/** `light` is the white pill the website uses on dark surfaces. */
tone?: "dark" | "light";
}> = ({ label, size = 28, tone = "dark" }) => {
const light = tone === "light";
const frame = useCurrentFrame();
const t = useT();
const circle = size * 2.7;
const angle = (frame / LAP_FRAMES) * 360;
const travelling = `conic-gradient(from ${angle}deg, transparent 0 58%, rgba(${DRAW.light}, 0.95) 76%, #ffffff 86%, rgba(${DRAW.light}, 0.95) 93%, transparent 100%)`;
const sweep = ((frame % SWEEP_FRAMES) / SWEEP_FRAMES) * 140 - 20;
const edge: CSSProperties = { position: "absolute", borderRadius: 9999, background: travelling };
return (
<span style={{ position: "relative", display: "inline-flex", borderRadius: 9999 }}>
<span style={{ position: "absolute", inset: -14, filter: "blur(12px)", opacity: 0.8 }}>
<span style={{ ...edge, inset: 8, ...ring(12) }} />
</span>
<span
style={{
position: "relative",
display: "inline-flex",
alignItems: "center",
gap: size * 1.1,
overflow: "hidden",
borderRadius: 9999,
backgroundColor: light ? COLOR.paper : COLOR.ink,
color: light ? COLOR.ink : COLOR.paper,
padding: `${size * 0.8}px ${size * 0.8}px ${size * 0.8}px ${size * 2.3}px`,
fontFamily: FONT.sans,
fontWeight: 700,
fontSize: size,
letterSpacing: "0.18em",
textTransform: "uppercase",
boxShadow: "0 20px 30px -10px rgba(15, 23, 42, 0.35)",
}}
>
<span
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(${size * 5}px circle at ${sweep}% 50%, rgba(${DRAW.light}, 0.5), transparent 70%)`,
}}
/>
<span style={{ position: "relative" }}>{t(label)}</span>
<span
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: circle,
height: circle,
borderRadius: 9999,
backgroundColor: light ? COLOR.ink : "rgba(255, 255, 255, 0.1)",
color: COLOR.paper,
}}
>
<svg
width={size * 1.2}
height={size * 1.2}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.25}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</span>
</span>
<span style={{ ...edge, inset: -2, ...ring(4) }} />
</span>
);
};
+94
View File
@@ -0,0 +1,94 @@
import type { FC, ReactNode } from "react";
import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { bitString } from "../lib/bits";
import { FONT } from "./fonts";
import { COLOR } from "./tokens";
const LOGO_SIZE = 36;
/** Width of one digit of the texture including its letter spacing, in px. */
const RAIN_COLUMN_WIDTH = 28.6;
const RAIN_ROW_HEIGHT = 40;
const RAIN_EVERY_FRAMES = 6;
const RAIN_OPACITY = 0.03;
/** Slowly changing field of digits behind everything, as quiet texture. Fills its box at any format. */
const BitRain: FC<{ inset: number }> = ({ inset }) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const columns = Math.ceil((width - inset * 2) / RAIN_COLUMN_WIDTH);
const tick = Math.floor(frame / RAIN_EVERY_FRAMES);
const rowCount = Math.ceil((height - inset * 2) / RAIN_ROW_HEIGHT);
const rows = Array.from({ length: rowCount }, (_, row) =>
bitString(columns, tick + row * 7),
);
return (
<div
style={{
position: "absolute",
top: inset,
left: inset,
right: inset,
bottom: inset,
fontFamily: FONT.mono,
fontWeight: 500,
fontSize: 26,
lineHeight: `${RAIN_ROW_HEIGHT}px`,
letterSpacing: "0.5em",
color: COLOR.ink,
opacity: RAIN_OPACITY,
overflow: "hidden",
whiteSpace: "pre",
padding: "0 24px",
}}
>
{rows.join("\n")}
</div>
);
};
/** Space kept free at the sides, the top and (for the footer) the bottom of the frame, in px. */
export const FRAME = { side: 48, top: 48, bottom: 104 } as const;
/**
* What surrounds the drawing: paper, a quiet digit texture, and one small line at the bottom with
* the logo and the address. Deliberately little (owner feedback): the drawing is what matters.
*/
export const BrandFrame: FC<{ children: ReactNode }> = ({ children }) => (
<AbsoluteFill style={{ backgroundColor: COLOR.paper }}>
<BitRain inset={0} />
<div
style={{
position: "absolute",
left: FRAME.side,
right: FRAME.side,
bottom: 36,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Img src={staticFile("logo.svg")} style={{ width: LOGO_SIZE, height: LOGO_SIZE, opacity: 0.55 }} />
<span
style={{
fontFamily: FONT.mono,
fontWeight: 500,
fontSize: 20,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: COLOR.muted,
}}
>
www.mintel.me
</span>
</div>
<AbsoluteFill
style={{
padding: `${FRAME.top}px ${FRAME.side}px ${FRAME.bottom}px`,
justifyContent: "center",
alignItems: "center",
}}
>
{children}
</AbsoluteFill>
</AbsoluteFill>
);
+134
View File
@@ -0,0 +1,134 @@
import type { CSSProperties, FC, ReactNode } from "react";
import { useCurrentFrame, useVideoConfig } from "remotion";
import { cometAt, outlinePoint, sparkAt } from "../lib/aura";
import { FONT } from "./fonts";
import { COLOR, DRAW } from "./tokens";
const RADIUS = 40;
const SPARKS = 150;
const DOT_GRID = 28;
const LIGHT_REACH = 360;
/** How far the light on the edge reaches into the panel and along the edge, in px. */
const EDGE_HAZE = 300;
const EDGE_LINE = 380;
const EDGE_WIDTH = 3;
/** Two lights travel the edge, opposite each other; the second is fainter. */
const COMETS = [
{ offset: 0, power: 1 },
{ offset: 0.5, power: 0.6 },
] as const;
/** Shows only a ring of the given thickness along the edge of its box. */
const ring = (thickness: number): CSSProperties => ({
padding: thickness,
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
});
const fill: CSSProperties = { position: "absolute", inset: 0 };
/**
* The dark panel the drawings sit on, with the website's effects (BitAura, BitGlow, bit-aurora):
* light travelling round its edge, binary digits drifting off it, a soft light wandering over
* the dot grid, and slow indigo light drifting behind the drawing.
*
* All light is drawn with plain gradients that fade to transparent: no blur filters, blend modes
* or dashed strokes, which render with hard edges frame by frame. Everything repeats exactly when
* the video loops. Must be rendered outside any Sequence, so that it sees the frame of the whole video.
*/
export const Panel: FC<{
width: number;
height: number;
/** Corner radius in px; 0 for a panel that fills the whole frame. */
radius?: number;
children: ReactNode;
}> = ({ width, height, radius = RADIUS, children }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const box = { width, height };
const turn = (frame / durationInFrames) * Math.PI * 2;
const lightX = width * (0.5 + 0.34 * Math.sin(turn * 2));
const lightY = height * (0.5 + 0.3 * Math.cos(turn * 3));
const comets = COMETS.map((comet) => ({
...outlinePoint(cometAt(frame, durationInFrames) + comet.offset, box),
power: comet.power,
}));
const sparks = Array.from({ length: SPARKS }, (_, index) => sparkAt(index, frame, durationInFrames, box));
const auroraX = 38 + Math.sin(turn) * 8;
const auroraY = 42 + Math.cos(turn) * 7;
return (
<div style={{ position: "relative", width, height }}>
<div
style={{
...fill,
borderRadius: radius,
overflow: "hidden",
backgroundColor: DRAW.bg,
backgroundImage: `radial-gradient(${DRAW.raised} 2px, transparent 2px)`,
backgroundSize: `${DOT_GRID}px ${DOT_GRID}px`,
boxShadow: "0 40px 80px -30px rgba(15, 23, 42, 0.55)",
}}
>
<div
style={{
...fill,
background: `radial-gradient(55% 65% at ${auroraX}% ${auroraY}%, rgba(${DRAW.glow}, 0.34), transparent 72%), radial-gradient(45% 55% at ${102 - auroraX}% ${112 - auroraY}%, rgba(${DRAW.light}, 0.2), transparent 72%)`,
}}
/>
<div
style={{
...fill,
backgroundImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, rgba(148, 163, 184, 0.2), transparent 70%), radial-gradient(#e2e8f0 1.75px, transparent 1.75px)`,
backgroundSize: `auto, ${DOT_GRID}px ${DOT_GRID}px`,
maskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
WebkitMaskImage: `radial-gradient(${LIGHT_REACH}px circle at ${lightX}px ${lightY}px, #000, transparent 75%)`,
}}
/>
{comets.map((comet) => (
<div
key={comet.power}
style={{
...fill,
background: `radial-gradient(${EDGE_HAZE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.light}, ${0.5 * comet.power}), rgba(${DRAW.glow}, ${0.22 * comet.power}) 40%, transparent 72%)`,
}}
/>
))}
<div style={fill}>{children}</div>
{comets.map((comet) => (
<div
key={comet.power}
style={{
...fill,
borderRadius: radius,
...ring(EDGE_WIDTH),
background: `radial-gradient(${EDGE_LINE}px circle at ${comet.x}px ${comet.y}px, rgba(${DRAW.core}, ${comet.power}), rgba(${DRAW.light}, ${0.7 * comet.power}) 35%, transparent 75%)`,
}}
/>
))}
</div>
<svg width={width} height={height} style={{ ...fill, overflow: "visible" }}>
{sparks.map((spark, index) =>
spark === null ? null : (
<text
key={index}
x={spark.x}
y={spark.y}
textAnchor="middle"
dominantBaseline="middle"
fontFamily={FONT.mono}
fontWeight={spark.large ? 700 : 500}
fontSize={spark.large ? 24 : 18}
fill={COLOR.ink}
opacity={spark.opacity * 0.8}
>
{spark.digit}
</text>
),
)}
</svg>
</div>
);
};
+14
View File
@@ -0,0 +1,14 @@
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";
import { loadFont as loadSerif } from "@remotion/google-fonts/Newsreader";
const inter = loadInter("normal", { weights: ["400", "600", "700", "900"], subsets: ["latin"] });
const mono = loadMono("normal", { weights: ["500", "700"], subsets: ["latin"] });
const serif = loadSerif("normal", { weights: ["400"], subsets: ["latin"] });
/** Same three families as the website: Inter for headlines, JetBrains Mono for labels, Newsreader for reading text. */
export const FONT = {
sans: inter.fontFamily,
mono: mono.fontFamily,
serif: serif.fontFamily,
} as const;
+42
View File
@@ -0,0 +1,42 @@
/** Brand tokens, mirrored from apps/web (Tailwind slate scale, white paper, mono labels). */
export const COLOR = {
paper: "#ffffff",
ink: "#0f172a", // slate-900
inkSoft: "#1e293b", // slate-800
body: "#475569", // slate-600
muted: "#64748b", // slate-500
faint: "#94a3b8", // slate-400
line: "#e2e8f0", // slate-200
wash: "#f8fafc", // slate-50
active: "#fbbf24", // amber-400: work in progress
done: "#10b981", // emerald-500: finished
} as const;
/** Colours for drawings, which sit on a dark panel like the animated panels of the website. */
export const DRAW = {
bg: "#0f172a", // slate-900: the panel
raised: "#1e293b", // slate-800: quiet surfaces on the panel
soft: "#334155", // slate-700: placeholder lines and blocks
label: "#94a3b8", // slate-400: mono labels
fg: "#f8fafc", // slate-50: lines, tiles, text
rim: "#64748b", // slate-500: fine outlines of quiet things
tint: "rgba(248, 250, 252, 0.14)", // a lit surface: a light tint instead of a solid block
glass: "rgba(15, 23, 42, 0.55)", // windows and cards: the dot grid shows through
mark: "#fde68a", // amber-200: work that is travelling
glow: "99, 102, 241", // indigo-500 as r, g, b: the light along the edge
light: "129, 140, 248", // indigo-400 as r, g, b: the light on buttons and nodes
core: "224, 231, 255", // indigo-100 as r, g, b: its bright centre
} as const;
/** Output formats. LinkedIn feed default is 4:5; the others are variants of the same video. */
export const FORMATS = {
feed: { width: 1080, height: 1350 },
square: { width: 1080, height: 1080 },
story: { width: 1080, height: 1920 },
/** 16:9 for YouTube and Google Ads video campaigns. */
wide: { width: 1920, height: 1080 },
} as const;
export type FormatName = keyof typeof FORMATS;
export const FPS = 30;
@@ -0,0 +1,6 @@
import { AppFilm } from "../films/AppFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
/** The week for an app that has become fragile. A film, not a deck. */
export const appRescue: VideoEntry = { id: "AppRescue", Film: AppFilm, durationInFrames: SHOT_FRAMES };
@@ -0,0 +1,6 @@
import { DocumentsFilm } from "../films/DocumentsFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
/** Incoming invoices that are read and booked without typing. A film, not a deck. */
export const automationDocuments: VideoEntry = { id: "AutomationDocuments", Film: DocumentsFilm, durationInFrames: SHOT_FRAMES };
@@ -0,0 +1,6 @@
import { EnquiryFilm } from "../films/EnquiryFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
/** An enquiry that is filed, passed on and followed up. A film, not a deck. */
export const automationEnquiry: VideoEntry = { id: "AutomationEnquiry", Film: EnquiryFilm, durationInFrames: SHOT_FRAMES };
@@ -0,0 +1,9 @@
import { ORDER_FILM_FRAMES, OrderFilm } from "../films/OrderFilm";
import type { VideoEntry } from "../slides/ServiceVideo";
/** Automation for laypeople: one online order, by hand and connected. A film, not a deck. */
export const automationOrder: VideoEntry = {
id: "AutomationOrder",
Film: OrderFilm,
durationInFrames: ORDER_FILM_FRAMES,
};
@@ -0,0 +1,6 @@
import { DeveloperFilm } from "../films/DeveloperFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
/** An experienced developer who joins a product team. A film, not a deck. */
export const productDeveloper: VideoEntry = { id: "ProductDeveloper", Film: DeveloperFilm, durationInFrames: SHOT_FRAMES };
@@ -0,0 +1,6 @@
import { WebsiteFilm } from "../films/WebsiteFilm";
import { SHOT_FRAMES } from "../films/shot";
import type { VideoEntry } from "../slides/ServiceVideo";
/** A website for people who want it taken care of. A film, not a deck. */
export const websiteDone: VideoEntry = { id: "WebsiteDone", Film: WebsiteFilm, durationInFrames: SHOT_FRAMES };
+23
View File
@@ -0,0 +1,23 @@
import { createContext, useContext } from "react";
import { translate, type Lang, type VideoCopy } from "./videos";
type CopyValue = { readonly lang: Lang; readonly copy: VideoCopy };
const CopyContext = createContext<CopyValue | null>(null);
export const CopyProvider = CopyContext.Provider;
const useCopyValue = (): CopyValue => {
const value = useContext(CopyContext);
if (!value) throw new Error("This component must be rendered inside a ServiceVideo");
return value;
};
/** The texts of the video that is being rendered, in its language. */
export const useCopy = (): VideoCopy => useCopyValue().copy;
/** Translates a label that is written in English in the drawing code. */
export const useT = (): ((text: string) => string) => {
const { lang } = useCopyValue();
return (text) => translate(lang, text);
};
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { COPY, LANGS, VIDEO_IDS, translate } from "./videos";
const MAX_LINES = 2;
/** Longer lines get too small to read on a phone. */
const MAX_LINE_LENGTH = 24;
describe("video copy", () => {
it.each(VIDEO_IDS)("%s has the same slides in every language", (id) => {
const [first, ...others] = LANGS.map((lang) => Object.keys(COPY[id][lang].slides).sort());
for (const slides of others) expect(slides).toEqual(first);
});
it.each(VIDEO_IDS)("%s keeps every headline to two short lines", (id) => {
for (const lang of LANGS) {
for (const [slide, lines] of Object.entries(COPY[id][lang].slides)) {
expect(lines.length, `${id} ${lang} ${slide}`).toBeGreaterThan(0);
expect(lines.length, `${id} ${lang} ${slide}`).toBeLessThanOrEqual(MAX_LINES);
for (const line of lines) {
expect(line.length, `${id} ${lang} ${slide}: "${line}"`).toBeLessThanOrEqual(MAX_LINE_LENGTH);
}
}
}
});
it.each(VIDEO_IDS)("%s ends with a call to action slide", (id) => {
for (const lang of LANGS) expect(COPY[id][lang].slides).toHaveProperty("cta");
});
it.each(VIDEO_IDS)("%s has a post that names the website and never an email address", (id) => {
for (const lang of LANGS) {
const { post } = COPY[id][lang];
expect(post).toContain("www.mintel.me");
expect(post).not.toContain("@");
}
});
});
describe("translate", () => {
it("leaves English labels as they are", () => {
expect(translate("en", "Invoice")).toBe("Invoice");
});
it("returns the German label when there is one", () => {
expect(translate("de", "Invoice")).toBe("Rechnung");
});
it("falls back to the given text when there is no German label", () => {
expect(translate("de", "Shop")).toBe("Shop");
});
});
+380
View File
@@ -0,0 +1,380 @@
/**
* Everything that is said in and about the videos: headlines per slide, labels inside the drawings,
* and the text to post with each video. English and German.
*
* This file imports nothing, so the export script can read it with plain Node.
* German addresses the viewer formally ("Sie"), as is usual towards business owners.
*/
export const LANGS = ["en", "de"] as const;
export type Lang = (typeof LANGS)[number];
export const VIDEO_IDS = [
"AutomationOrder",
"AutomationDocuments",
"AutomationEnquiry",
"WebsiteDone",
"AppRescue",
"ProductDeveloper",
] as const;
export type VideoId = (typeof VIDEO_IDS)[number];
export type VideoCopy = {
/** What the video is about, for the overview page. */
readonly title: string;
/** Headline per slide id: at most two lines of a few words. */
readonly slides: Readonly<Record<string, readonly string[]>>;
/** Text to post with the video on LinkedIn. */
readonly post: string;
};
/** German for every label that appears inside a drawing or on the frame. Missing entries stay English. */
export const LABELS_DE: Readonly<Record<string, string>> = {
"Freelance developer": "Freelance-Entwickler",
"Get in touch": "Kontakt aufnehmen",
Invoice: "Rechnung",
Customers: "Kunden",
Email: "E-Mail",
"One person": "Eine Person",
Inbox: "Posteingang",
Read: "Lesen",
Check: "Prüfen",
Books: "Buchhaltung",
"Invoice.pdf": "Rechnung.pdf",
Accounting: "Buchhaltung",
Supplier: "Lieferant",
Amount: "Betrag",
Date: "Datum",
"A person": "Ein Mensch",
Contacts: "Kontakte",
Reminder: "Erinnerung",
"Day 1": "Tag 1",
"Day 2": "Tag 2",
"Day 3": "Tag 3",
"Day 4": "Tag 4",
"Website builder": "Baukasten",
"Your website": "Ihre Website",
"Your email": "Ihre E-Mail",
"www.your-name.com": "www.ihr-name.de",
"Your app": "Ihre App",
Mon: "Mo",
Tue: "Di",
Wed: "Mi",
Thu: "Do",
Fri: "Fr",
"To do": "Offen",
Done: "Erledigt",
Product: "Produkt",
Developer: "Entwicklung",
};
export const translate = (lang: Lang, text: string): string =>
lang === "de" ? (LABELS_DE[text] ?? text) : text;
export const COPY: Readonly<Record<VideoId, Readonly<Record<Lang, VideoCopy>>>> = {
AutomationOrder: {
en: {
title: "Automation: from order to invoice",
slides: {
hook: ["Looks like", "ping-pong."],
reveal: ["It's your", "office."],
problem: ["Every order.", "By hand."],
turn: ["Or your tools", "talk to each other."],
calm: ["And your team", "has time."],
cta: ["Done with", "ping-pong?"],
},
post: `Copy, paste, repeat.
In many companies an order still travels by hand: someone opens it in the shop, writes the invoice, updates the customer record and sends the confirmation.
That works, until the orders pile up.
The tools involved can usually pass the work on to each other. I connect them, so that the order triggers the invoice, the customer record and the email on its own. Your people see the finished result and look after the cases that need a person.
Is a process like this taking up your team's time? Write to me. A first call takes about 20 minutes.
www.mintel.me
#automation #processautomation #smallbusiness`,
},
de: {
title: "Automatisierung: von der Bestellung zur Rechnung",
slides: {
hook: ["Sieht aus wie", "Pingpong."],
reveal: ["Ist aber", "Ihr Büro."],
problem: ["Jede Bestellung.", "Von Hand."],
turn: ["Oder Ihre Tools", "reden miteinander."],
calm: ["Und Ihr Team", "hat Zeit."],
cta: ["Schluss mit", "Pingpong?"],
},
post: `Sieht aus wie Pingpong. Ist aber Ihr Büro.
Eine Bestellung kommt rein. Jemand öffnet sie im Shop, schreibt die Rechnung, pflegt den Kunden nach, verschickt die Bestätigung. Dann die nächste. Und die nächste.
Klappt wunderbar, solange kaum jemand bestellt.
Dabei können die vier Tools das längst unter sich ausmachen: Die Bestellung löst Rechnung, Kundendatensatz und E-Mail selbst aus. Ihr Team sieht das fertige Ergebnis und kümmert sich um die Fälle, die wirklich einen Menschen brauchen.
Ich verbinde die Tools, die Sie schon haben. Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#automatisierung #prozessautomatisierung #mittelstand`,
},
},
AutomationDocuments: {
en: {
title: "Automation: incoming invoices",
slides: {
hook: ["Looks like", "a skyscraper."],
reveal: ["It's your", "inbox."],
problem: ["Every invoice.", "Typed in."],
turn: ["Or it simply", "gets read."],
calm: ["Unclear ones go", "to a person."],
cta: ["Done typing", "things in?"],
},
post: `Invoices arrive as PDF. Then someone opens each one, reads supplier, amount and date, and types them into the accounting system.
Every single one.
It does not have to be done by hand. The document can be read automatically, a fixed rule checks whether the values make sense, and clear cases are booked directly. Anything unclear goes to a person, with the document attached.
So people decide the exceptions instead of typing the routine.
If documents pile up in your inbox, write to me. A first call takes about 20 minutes.
www.mintel.me
#automation #accounting #smallbusiness`,
},
de: {
title: "Automatisierung: Eingangsrechnungen",
slides: {
hook: ["Sieht aus wie", "ein Hochhaus."],
reveal: ["Ist aber Ihr", "Posteingang."],
problem: ["Jede Rechnung.", "Abgetippt."],
turn: ["Oder sie wird", "einfach gelesen."],
calm: ["Unklares geht", "an einen Menschen."],
cta: ["Schluss mit", "Abtippen?"],
},
post: `Rechnungen kommen als PDF. Dann öffnet jemand jede einzelne, liest Lieferant, Betrag und Datum ab und tippt alles in die Buchhaltung.
Jede einzelne.
Das muss niemand von Hand machen. Das Dokument lässt sich automatisch auslesen, eine feste Regel prüft, ob die Werte plausibel sind, und klare Fälle werden direkt gebucht. Alles Unklare geht an einen Menschen, mit dem Dokument im Anhang.
So entscheiden Menschen die Ausnahmen, statt Routine abzutippen.
Stapeln sich bei Ihnen die Belege im Posteingang? Schreiben Sie mir. Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#automatisierung #buchhaltung #mittelstand`,
},
},
AutomationEnquiry: {
en: {
title: "Automation: from enquiry to follow-up",
slides: {
hook: ["Looks like", "a star."],
reveal: ["These are your", "enquiries."],
problem: ["They burn out.", "Unanswered."],
turn: ["Or each one reaches", "the right person."],
calm: ["And follow-ups", "happen by themselves."],
cta: ["Stop losing", "enquiries?"],
},
post: `Someone fills in your contact form. And then?
Often the enquiry is copied into the customer list by hand, forwarded by email, and everyone hopes the follow-up is not forgotten.
These steps can be connected: the contact is created, the right person is told, and if nobody has replied after three days, a reminder appears.
Nothing clever. Just the steps that are done by hand today, done reliably.
If enquiries get lost between your tools, write to me. A first call takes about 20 minutes.
www.mintel.me
#automation #sales #smallbusiness`,
},
de: {
title: "Automatisierung: von der Anfrage zum Nachfassen",
slides: {
hook: ["Sieht aus wie", "ein Stern."],
reveal: ["Sind aber Ihre", "Anfragen."],
problem: ["Sie verglühen.", "Unbeantwortet."],
turn: ["Oder jede landet", "beim Richtigen."],
calm: ["Und Nachfassen", "passiert von selbst."],
cta: ["Keine Anfrage", "mehr verlieren?"],
},
post: `Jemand füllt Ihr Kontaktformular aus. Und dann?
Oft wird die Anfrage von Hand in die Kundenliste kopiert, per E-Mail weitergeleitet, und alle hoffen, dass das Nachfassen nicht vergessen wird.
Diese Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn nach drei Tagen niemand geantwortet hat, erscheint eine Erinnerung.
Nichts Kompliziertes. Nur die Schritte, die heute von Hand passieren, zuverlässig erledigt.
Gehen bei Ihnen Anfragen zwischen den Tools verloren? Schreiben Sie mir. Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#automatisierung #vertrieb #mittelstand`,
},
},
WebsiteDone: {
en: {
title: "A website, built and looked after",
slides: {
hook: ["Looks like", "a beginning."],
reveal: ["It's been your", "website for months."],
problem: ["Empty.", "Still."],
turn: ["You talk.", "I build."],
calm: ["Fits every", "screen."],
cta: ["Website", "overdue?"],
},
post: `You run a business. You do not want to learn a website builder on top of that.
This is how I do it instead:
You tell me what your business does and what the website is for. I design it, build it and put it online. It works on phone, tablet and desktop, and you approve it before it goes live.
And afterwards? A new photo or a changed price: you send me an email and I make the change.
If your website has been on your list for too long, write to me. A first call takes about 20 minutes.
www.mintel.me
#website #webdesign #smallbusiness`,
},
de: {
title: "Eine Website, gebaut und betreut",
slides: {
hook: ["Sieht aus wie", "ein Anfang."],
reveal: ["Ist seit Monaten", "Ihre Website."],
problem: ["Leer.", "Immer noch."],
turn: ["Sie erzählen.", "Ich baue."],
calm: ["Passt auf", "jeden Bildschirm."],
cta: ["Website", "fällig?"],
},
post: `Sie führen ein Unternehmen. Einen Website-Baukasten wollen Sie nicht auch noch lernen.
So mache ich es stattdessen:
Sie erzählen mir, was Ihr Unternehmen macht und wofür die Website da ist. Ich gestalte sie, baue sie und bringe sie online. Sie funktioniert auf Handy, Tablet und Desktop, und Sie geben sie frei, bevor sie live geht.
Und danach? Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail, ich setze es um.
Steht Ihre Website schon zu lange auf der Liste? Schreiben Sie mir. Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#website #webdesign #mittelstand`,
},
},
AppRescue: {
en: {
title: "Making an app reliable",
slides: {
hook: ["Looks like", "Jenga."],
reveal: ["It's your", "app."],
problem: ["Every change", "wobbles."],
turn: ["One week.", "I look inside."],
calm: ["After that", "it stands."],
cta: ["App", "wobbling?"],
},
post: `Your app works. But bugs come back, and small changes take longer than they should.
That usually happens when an app was built quickly and then kept growing.
I take a week to go through it: I look at what is solid and what is fragile, fix the most urgent problems as far as a week allows, and write down what I would do next. In plain language, so you can follow it without being a developer.
After that you decide how to continue: with me, with your own team, or not at all.
If that sounds like your app, write to me. A first call takes about 20 minutes.
www.mintel.me
#webdevelopment #softwarequality #startups`,
},
de: {
title: "Eine App zuverlässig machen",
slides: {
hook: ["Sieht aus wie", "Jenga."],
reveal: ["Ist aber", "Ihre App."],
problem: ["Jede Änderung", "wackelt."],
turn: ["Eine Woche.", "Ich sehe hinein."],
calm: ["Danach", "steht sie."],
cta: ["App", "wackelt?"],
},
post: `Ihre App läuft. Aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten.
Das passiert meist, wenn eine App schnell gebaut wurde und dann immer weiter gewachsen ist.
Ich nehme mir eine Woche Zeit dafür: Ich sehe mir an, was solide ist und was wackelt, behebe die dringendsten Probleme, soweit eine Woche reicht, und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache, die Sie auch ohne Entwicklerwissen verstehen.
Danach entscheiden Sie, wie es weitergeht: mit mir, mit Ihrem eigenen Team oder gar nicht.
Klingt das nach Ihrer App? Schreiben Sie mir. Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#webentwicklung #softwarequalität #startups`,
},
},
ProductDeveloper: {
en: {
title: "A developer for your product",
slides: {
hook: ["Looks like", "a loading bar."],
reveal: ["It's your", "backlog."],
problem: ["Growing faster", "than your team."],
turn: ["One more", "developer."],
calm: ["Fixed days.", "No minimum term."],
cta: ["Backlog", "too long?"],
},
post: `You have a product, and more work than your team can handle.
I join on agreed days of the week and work on what is next on your list: new features, the interface, or the parts nobody gets around to.
Finished parts go to you for review as they are done. I track my hours, and you can see what they were spent on. There is no package to buy and no minimum term: either side can end it with two weeks notice to the end of a month.
15 years in web development, in agencies and product teams.
If your backlog is growing faster than your team, write to me. A first call takes about 20 minutes.
www.mintel.me
#webdevelopment #freelance #react`,
},
de: {
title: "Ein Entwickler für Ihr Produkt",
slides: {
hook: ["Sieht aus wie", "ein Ladebalken."],
reveal: ["Ist aber", "Ihr Backlog."],
problem: ["Wächst schneller", "als Ihr Team."],
turn: ["Ein Entwickler", "mehr."],
calm: ["Feste Tage.", "Keine Mindestlaufzeit."],
cta: ["Backlog", "zu lang?"],
},
post: `Sie haben ein Produkt und mehr Arbeit, als Ihr Team schafft.
Ich komme an vereinbarten Tagen in der Woche dazu und arbeite an dem, was als Nächstes auf Ihrer Liste steht: neue Funktionen, die Oberfläche oder die Teile, zu denen niemand kommt.
Fertige Teile gehen zur Durchsicht an Sie, sobald sie fertig sind. Ich erfasse meine Stunden, und Sie sehen, wofür sie verwendet wurden. Es gibt kein Paket und keine Mindestlaufzeit: Beide Seiten können mit zwei Wochen Frist zum Monatsende beenden.
15 Jahre Webentwicklung, in Agenturen und Produktteams.
Wächst Ihr Backlog schneller als Ihr Team? Schreiben Sie mir. Ein erstes Gespräch dauert etwa 20 Minuten.
www.mintel.me
#webentwicklung #freelancer #react`,
},
},
};
+106
View File
@@ -0,0 +1,106 @@
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>
);
+93
View File
@@ -0,0 +1,93 @@
import type { FC } from "react";
import { useCurrentFrame } from "remotion";
import { COLOR, DRAW } from "../brand/tokens";
import { mix, out, progress } from "../illustrations/motion";
import { FilmStage } from "./FilmStage";
import { CAPTIONS, SURFACE, T, cameraAt, glow } from "./shot";
/**
* Close up, a bar fills slowly: a loading bar. The camera pulls back: it is the first of a long
* list, the backlog, and new entries arrive faster than the team gets through them. Then one more
* developer joins, and the list is worked off, entry after entry.
*/
const ROW = { x: -215, width: 470, height: 26, pitch: 44, top: -270 } as const;
const MAX_ROWS = 13;
const OPENING_ROWS = 4;
const ROW_EVERY = 26;
const TEAM_SIZE = 3;
const WORKER_X = ROW.x - 42;
const DONE_EVERY = 13;
const OPENING_FILL = 0.15;
const rowY = (index: number): number => ROW.top + index * ROW.pitch;
const doneStart = (index: number): number => T.resolve + index * DONE_EVERY;
const Drawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 1.7, 1, { x: 0, y: rowY(0) });
const { zoom, hair, others } = camera;
const folded = frame >= T.fold;
const rows = folded ? 1 : Math.min(MAX_ROWS, OPENING_ROWS + Math.max(0, frame - T.reveal) / ROW_EVERY);
const dot = 12 / Math.sqrt(zoom);
const joined = out(progress(frame, T.turn, T.turn + 28));
return (
<g transform={camera.transform}>
{Array.from({ length: MAX_ROWS }, (_, index) => {
if (index >= rows) return null;
const y = rowY(index);
const arrived = Math.min(1, rows - index);
const worked = index === 0 ? mix(OPENING_FILL, 0.6, progress(frame, 0, T.turn)) : index < TEAM_SIZE ? mix(0, 0.4, progress(frame, T.reveal, T.turn)) : 0;
const finishing = folded ? 0 : out(progress(frame, doneStart(index), doneStart(index) + 16));
const leaving = folded ? 0 : progress(frame, doneStart(index) + 18, doneStart(index) + 32);
const fill = folded ? OPENING_FILL : mix(worked, 1, finishing);
const shown = folded ? progress(frame, T.fold + 16, T.fold + 44) : arrived * (1 - leaving) * (index === 0 ? 1 : others);
if (shown === 0) return null;
return (
<g key={index} opacity={shown} transform={`translate(${-36 * leaving} ${(1 - out(arrived)) * 26})`}>
<rect
x={ROW.x}
y={y - ROW.height / 2}
width={ROW.width}
height={ROW.height}
rx={ROW.height / 2}
fill={SURFACE.fill}
stroke={DRAW.fg}
strokeOpacity={0.5}
strokeWidth={hair}
/>
{fill > 0 && (
<rect
x={ROW.x}
y={y - ROW.height / 2}
width={Math.max(ROW.height, ROW.width * fill)}
height={ROW.height}
rx={ROW.height / 2}
fill={finishing === 1 ? COLOR.done : DRAW.fg}
/>
)}
</g>
);
})}
{Array.from({ length: TEAM_SIZE }, (_, index) => (
<circle key={index} cx={WORKER_X} cy={rowY(index)} r={dot} fill={DRAW.fg} opacity={index === 0 ? 1 : others} />
))}
<circle
cx={mix(WORKER_X - 150, WORKER_X, joined)}
cy={rowY(TEAM_SIZE)}
r={dot}
fill={DRAW.mark}
opacity={joined * others}
style={glow(DRAW.mark, 14, zoom)}
/>
</g>
);
};
/** A developer for a product: from a backlog that grows faster than the team to a list worked off. */
export const DeveloperFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<Drawing />
</FilmStage>
);
+128
View File
@@ -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>
);
+124
View File
@@ -0,0 +1,124 @@
import type { FC } from "react";
import { useCurrentFrame } from "remotion";
import { DRAW } from "../brand/tokens";
import { mix, out, progress } from "../illustrations/motion";
import type { Point } from "../lib/hop";
import { FilmStage } from "./FilmStage";
import { CAPTIONS, SURFACE, T, cameraAt, glow, untilFold } from "./shot";
/**
* Close up, one point of light: a star. The camera pulls back: a whole sky of them, each one an
* enquiry, and one after the other they burn out unanswered. Then a line reaches every one of them
* from the team in the middle, they light up again, and a quiet ring keeps reminding.
*/
const TEAM = { x: 0, y: 10, size: 120 } as const;
/** The first one is the star of the opening picture. */
const STARS: readonly Point[] = [
{ x: -190, y: -90 },
{ x: -380, y: -210 },
{ x: -60, y: -260 },
{ x: 210, y: -220 },
{ x: 400, y: -130 },
{ x: 320, y: 50 },
{ x: 420, y: 230 },
{ x: 170, y: 250 },
{ x: -90, y: 230 },
{ x: -300, y: 250 },
{ x: -430, y: 50 },
{ x: -250, y: 110 },
{ x: 150, y: -90 },
];
const HERO: Point = STARS[0] ?? { x: 0, y: 0 };
const TWINKLE_FRAMES = 23;
const RING_FRAMES = 60;
const lineStart = (index: number): number => T.resolve + index * 6;
/** How brightly an enquiry still shines: it fades while nobody answers and returns once it is reached. */
const brightness = (index: number, frame: number): number => {
if (frame >= T.fold) return 1;
const fadeFrom = index === 0 ? 0 : 60 + index * 17;
const faded = progress(frame, fadeFrom, fadeFrom + (index === 0 ? 170 : 90));
const reached = out(progress(frame, lineStart(index) + 18, lineStart(index) + 40));
return mix(1 - 0.88 * faded, 1, reached);
};
const Drawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 3, 0.95, HERO);
const { zoom, hair, others } = camera;
const radius = 11 / Math.sqrt(zoom);
const sinceCalm = frame - T.calm;
return (
<g transform={camera.transform}>
{STARS.map((star, index) => {
const drawn = out(progress(frame, lineStart(index), lineStart(index) + 30)) * untilFold(frame);
return drawn === 0 ? null : (
<path
key={index}
d={`M${TEAM.x} ${TEAM.y}L${mix(TEAM.x, star.x, drawn)} ${mix(TEAM.y, star.y, drawn)}`}
stroke={DRAW.fg}
strokeOpacity={0.6}
strokeWidth={hair}
/>
);
})}
{sinceCalm > 0 &&
[0, RING_FRAMES / 2].map((delay) => {
const t = ((sinceCalm + delay) % RING_FRAMES) / RING_FRAMES;
return (
<circle
key={delay}
cx={TEAM.x}
cy={TEAM.y}
r={mix(TEAM.size * 0.6, TEAM.size * 1.5, t)}
fill="none"
stroke={`rgba(${DRAW.light}, 1)`}
strokeWidth={hair}
opacity={(1 - t) * 0.7 * untilFold(frame) * progress(sinceCalm, 0, 20)}
/>
);
})}
<g opacity={others}>
<rect x={TEAM.x - TEAM.size / 2} y={TEAM.y - TEAM.size / 2} width={TEAM.size} height={TEAM.size} rx={TEAM.size * 0.24} fill={DRAW.bg} />
<rect
x={TEAM.x - TEAM.size / 2}
y={TEAM.y - TEAM.size / 2}
width={TEAM.size}
height={TEAM.size}
rx={TEAM.size * 0.24}
fill={SURFACE.fill}
stroke={DRAW.fg}
strokeOpacity={SURFACE.strokeOpacity}
strokeWidth={hair}
/>
</g>
{STARS.map((star, index) => {
const twinkle = 0.92 + 0.08 * Math.sin((frame / TWINKLE_FRAMES) * Math.PI * 2 + index);
const shine = brightness(index, frame) * twinkle;
return (
<circle
key={index}
cx={star.x}
cy={star.y}
r={radius}
fill={DRAW.mark}
opacity={shine * (index === 0 ? 1 : others)}
style={glow(DRAW.mark, 16 * shine, zoom)}
/>
);
})}
</g>
);
};
/** Enquiries: from stars that burn out unanswered to every one reaching the right person. */
export const EnquiryFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<Drawing />
</FilmStage>
);
+181
View File
@@ -0,0 +1,181 @@
import type { FC, ReactNode } from "react";
import { Easing, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { BrandButton } from "../brand/BrandButton";
import { FONT } from "../brand/fonts";
import { DRAW } from "../brand/tokens";
import { useCopy } from "../content/CopyContext";
import { headlineSize } from "../slides/SlideDeck";
/** Films are drawn in this coordinate system, with (0, 0) in the centre; the stage is scaled to fit. */
export const STAGE = { width: 1000, height: 700 } as const;
/** One statement: which text (key in the video's copy) and while which frames. */
export type Caption = {
readonly id: string;
readonly from: number;
readonly to: number;
/** Show the website's button below this statement. */
readonly button?: boolean;
};
const SIDE = 60;
const FOOTER_HEIGHT = 130;
const MARK_HEIGHT = 76;
const GAP = 44;
/** Room for two lines and the button below the stage, in px. */
const STATEMENT_HEIGHT = 330;
const MAX_WIDE_FONT = 88;
const IN_FRAMES = 18;
const OUT_FRAMES = 10;
const RISE_PX = 18;
const EDGE_FADE =
"linear-gradient(to bottom, transparent, #000 9%, #000 91%, transparent), linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)";
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
/** Fast start, long soft landing: the easing of a calm presentation. */
const settle = Easing.bezier(0.16, 1, 0.3, 1);
const Statement: FC<{ caption: Caption; first: boolean; fontSize: number; total: number }> = ({
caption,
first,
fontSize,
total,
}) => {
const frame = useCurrentFrame();
const lines = useCopy().slides[caption.id] ?? [];
// The first statement stands on frame 0 (the preview image) and returns at the very end.
const back = first ? interpolate(frame, [total - OUT_FRAMES, total - 1], [0, 1], clampOpts) : 0;
const arrive = first ? 1 : settle(interpolate(frame, [caption.from, caption.from + IN_FRAMES], [0, 1], clampOpts));
const leave = interpolate(frame, [caption.to - OUT_FRAMES, caption.to - 1], [1, 0], clampOpts);
const opacity = Math.max(frame < caption.to ? Math.min(arrive, leave) : 0, back);
if (opacity === 0) return null;
return (
<div
style={{
position: "absolute",
inset: 0,
textAlign: "center",
opacity,
transform: `translateY(${(1 - arrive) * RISE_PX}px)`,
}}
>
{lines.map((line, index) => (
<div
key={line}
style={{
fontFamily: FONT.sans,
fontWeight: 900,
fontSize,
lineHeight: 1.06,
letterSpacing: "-0.04em",
wordSpacing: "0.08em",
color: index > 0 && index === lines.length - 1 ? DRAW.label : DRAW.fg,
}}
>
{line}
</div>
))}
{caption.button && (
<div style={{ marginTop: fontSize * 0.55 }}>
<BrandButton label="Get in touch" size={Math.round(fontSize * 0.3)} tone="light" />
</div>
)}
</div>
);
};
/**
* What every film shares: a quiet dark backdrop, the drawing in the middle, one statement at a time
* centred below it, and the signature. Deliberately little (owner feedback: minimal, calm, one idea
* at a time). The film itself is drawn by `children` as SVG content in STAGE coordinates.
*/
export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }> = ({ captions, children }) => {
const frame = useCurrentFrame();
const { width, height, durationInFrames } = useVideoConfig();
const copy = useCopy();
const wide = width > height;
const scale = Math.min(
(width - SIDE * 2) / STAGE.width,
(height - FOOTER_HEIGHT - GAP - STATEMENT_HEIGHT - 50) / STAGE.height,
);
const stageHeight = STAGE.height * scale;
const stageTop = (height - FOOTER_HEIGHT - stageHeight - GAP - STATEMENT_HEIGHT) / 2 + 20;
const textWidth = width - SIDE * 2;
// One size for the whole film: the largest at which its longest line still fits.
const fontSize = Math.min(
wide ? MAX_WIDE_FONT : Infinity,
headlineSize(captions.flatMap((caption) => copy.slides[caption.id] ?? []), textWidth),
);
const turn = (frame / durationInFrames) * Math.PI * 2;
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", backgroundColor: DRAW.bg }}>
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(60% 50% at ${50 + Math.sin(turn) * 10}% ${38 + Math.cos(turn) * 6}%, rgba(${DRAW.glow}, 0.3), transparent 75%)`,
}}
/>
{/* The signature, very large and faint, as part of the background. */}
<Img
src={staticFile("logo-mark.svg")}
style={{
position: "absolute",
right: -width * 0.12,
bottom: -width * 0.06,
width: width * (wide ? 0.5 : 0.95),
opacity: 0.04,
}}
/>
<svg
width={STAGE.width * scale}
height={stageHeight}
viewBox={`${-STAGE.width / 2} ${-STAGE.height / 2} ${STAGE.width} ${STAGE.height}`}
// While the camera moves, the drawing is larger than the stage: it fades out at the stage's edges.
style={{
position: "absolute",
left: (width - STAGE.width * scale) / 2,
top: stageTop,
maskImage: EDGE_FADE,
WebkitMaskImage: EDGE_FADE,
maskComposite: "intersect",
WebkitMaskComposite: "source-in",
}}
>
{children}
</svg>
<div style={{ position: "absolute", left: SIDE, width: textWidth, top: stageTop + stageHeight + GAP, height: STATEMENT_HEIGHT }}>
{captions.map((caption, index) => (
<Statement key={caption.id} caption={caption} first={index === 0} fontSize={fontSize} total={durationInFrames} />
))}
</div>
<div
style={{
position: "absolute",
left: SIDE,
right: SIDE,
bottom: 44,
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
}}
>
<Img src={staticFile("logo-mark.svg")} style={{ height: MARK_HEIGHT, opacity: 0.92 }} />
<span
style={{
fontFamily: FONT.mono,
fontWeight: 500,
fontSize: 22,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: DRAW.label,
}}
>
www.mintel.me
</span>
</div>
</div>
);
};
+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>
);
+132
View File
@@ -0,0 +1,132 @@
import type { FC } from "react";
import { Easing, useCurrentFrame } from "remotion";
import { DRAW } from "../brand/tokens";
import { inOut, mix, progress } from "../illustrations/motion";
import { FilmStage } from "./FilmStage";
import { CAPTIONS, SURFACE, T, cameraAt, untilFold } from "./shot";
/**
* Close up, a blinking cursor: a beginning. The camera pulls back: it is an empty page, and it has
* been empty for months. Then the page builds itself, block by block, and becomes three screens.
*/
const WINDOW = { width: 640, height: 440 } as const;
const BAR_HEIGHT = 50;
const CURSOR = { x: -250, y: -110, width: 9, height: 62 } as const;
const BLINK_FRAMES = 30;
const settle = Easing.bezier(0.16, 1, 0.3, 1);
/** The blocks of a simple page, in the coordinates of a screen of the given size. */
const Page: FC<{ width: number; height: number; columns: number; shown: (block: number) => number }> = ({
width,
height,
columns,
shown,
}) => {
const pad = width * 0.07;
const inner = width - pad * 2;
const top = -height / 2 + pad;
const heroHeight = height * 0.42;
const cardsTop = top + heroHeight + pad * 0.6;
const cardHeight = height / 2 - pad - cardsTop;
const gap = pad * 0.5;
const cardWidth = (inner - gap * (columns - 1)) / columns;
const block = (index: number): { opacity: number; transform: string } => ({
opacity: shown(index),
transform: `translate(0 ${(1 - shown(index)) * 22})`,
});
return (
<>
<g {...block(0)}>
<rect x={-inner / 2} y={top} width={inner} height={heroHeight} rx={pad * 0.45} fill={DRAW.fg} />
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.34} width={inner * 0.5} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} />
<rect x={-inner / 2 + pad} y={top + heroHeight * 0.58} width={inner * 0.3} height={heroHeight * 0.13} rx={heroHeight * 0.065} fill={DRAW.bg} opacity={0.45} />
</g>
{Array.from({ length: columns }, (_, column) => (
<rect
key={column}
{...block(1 + column)}
x={-inner / 2 + column * (cardWidth + gap)}
y={cardsTop}
width={cardWidth}
height={cardHeight}
rx={pad * 0.45}
fill="rgba(248, 250, 252, 0.22)"
/>
))}
</>
);
};
const Screen: FC<{ width: number; height: number; hair: number; radius: number }> = ({ width, height, hair, radius }) => (
<>
<rect x={-width / 2} y={-height / 2} width={width} height={height} rx={radius} fill={DRAW.bg} />
<rect
x={-width / 2}
y={-height / 2}
width={width}
height={height}
rx={radius}
fill={SURFACE.fill}
stroke={DRAW.fg}
strokeOpacity={SURFACE.strokeOpacity}
strokeWidth={hair}
/>
</>
);
const DEVICES = [
{ x: 195, y: 20, width: 190, height: 270, columns: 2 },
{ x: 380, y: 50, width: 112, height: 210, columns: 1 },
] as const;
const Drawing: FC = () => {
const frame = useCurrentFrame();
const camera = cameraAt(frame, 3, 1, CURSOR);
const { hair, others } = camera;
const blink = Math.cos((frame / BLINK_FRAMES) * Math.PI * 2) > -0.2 ? 1 : 0.12;
const cursorShown = 1 - progress(frame, T.turn, T.turn + 12) + progress(frame, T.fold + 24, T.fold + 44);
const built = (block: number): number =>
settle(progress(frame, T.resolve - 12 + block * 12, T.resolve + 22 + block * 12)) * untilFold(frame);
const split = inOut(progress(frame, T.calm, T.calm + 50)) * (1 - inOut(progress(frame, T.fold, T.fold + 30)));
const contentHeight = WINDOW.height - BAR_HEIGHT;
return (
<g transform={camera.transform}>
{DEVICES.map((device) => (
<g key={device.x} opacity={split} transform={`translate(${mix(-120, device.x, split)} ${device.y})`}>
<Screen width={device.width} height={device.height} hair={hair} radius={22} />
<Page width={device.width} height={device.height} columns={device.columns} shown={() => 1} />
</g>
))}
<g transform={`translate(${mix(0, -170, split)} 0) scale(${mix(1, 0.72, split)})`}>
<g opacity={others}>
<Screen width={WINDOW.width} height={WINDOW.height} hair={hair} radius={30} />
<path d={`M${-WINDOW.width / 2} ${-WINDOW.height / 2 + BAR_HEIGHT}h${WINDOW.width}`} stroke={DRAW.fg} strokeOpacity={0.4} strokeWidth={hair} />
{[0, 1, 2].map((n) => (
<circle key={n} cx={-WINDOW.width / 2 + 30 + n * 24} cy={-WINDOW.height / 2 + BAR_HEIGHT / 2} r={7} fill={DRAW.fg} opacity={0.3} />
))}
</g>
<g transform={`translate(0 ${BAR_HEIGHT / 2})`}>
<Page width={WINDOW.width} height={contentHeight} columns={3} shown={built} />
</g>
<rect
x={CURSOR.x - CURSOR.width / 2}
y={CURSOR.y - CURSOR.height / 2}
width={CURSOR.width}
height={CURSOR.height}
rx={CURSOR.width / 2}
fill={DRAW.mark}
opacity={Math.min(1, cursorShown) * blink}
/>
</g>
</g>
);
};
/** A website: from a page that has been empty for months to one that is built and fits every screen. */
export const WebsiteFilm: FC = () => (
<FilmStage captions={CAPTIONS}>
<Drawing />
</FilmStage>
);
+75
View File
@@ -0,0 +1,75 @@
import { inOut, progress } from "../illustrations/motion";
import type { Point } from "../lib/hop";
import type { Caption } from "./FilmStage";
/**
* What the films share: one timeline and one camera move. Every film is a single shot:
* close on something small, pull back to see what it really is, stop, resolve, rest,
* and return to the opening picture so that it loops.
*/
export const SHOT_FRAMES = 690;
export const T = {
/** The camera starts to pull back. */
reveal: 75,
/** The whole picture is in view. */
wide: 195,
/** Everything slows to a halt. */
freeze: 310,
/** The turn begins. */
turn: 345,
/** The problem resolves. */
resolve: 365,
/** The calm result. */
calm: 440,
cta: 560,
/** The picture folds back into its opening. */
fold: 630,
} as const;
/** The six statements of a film, in order: hook, reveal, problem, turn, calm, call to action. */
export 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.turn, to: T.calm + 20 },
{ id: "calm", from: T.calm + 20, to: T.cta },
{ id: "cta", from: T.cta, to: SHOT_FRAMES, button: true },
];
/** 0 while the camera is close, 1 when it has pulled back. */
export const pulledBack = (frame: number): number =>
inOut(progress(frame, T.reveal, T.wide)) * (1 - inOut(progress(frame, T.fold, SHOT_FRAMES)));
/** 1 until the fold, then quickly 0: for everything that has to be gone before the loop closes. */
export const untilFold = (frame: number): number => 1 - progress(frame, T.fold, T.fold + 24);
export type Camera = {
readonly zoom: number;
/** Opacity of everything outside the opening picture: it fades in as the camera pulls back. */
readonly others: number;
/** Width of a hairline that looks the same at every zoom. */
readonly hair: number;
/** SVG transform for the group that holds the drawing. */
readonly transform: string;
};
/** The camera at a frame: close on `focus` at `zoomIn`, then the whole stage at `zoomOut`. */
export const cameraAt = (frame: number, zoomIn: number, zoomOut: number, focus: Point): Camera => {
const back = pulledBack(frame);
const zoom = zoomIn * (zoomOut / zoomIn) ** back;
return {
zoom,
others: progress(back, 0.05, 0.5),
hair: 2.2 / zoom,
transform: `scale(${zoom}) translate(${-focus.x * (1 - back)} ${-focus.y * (1 - back)})`,
};
};
/** A soft light around a shape, the same size on screen at every zoom. */
export const glow = (color: string, px: number, zoom: number): { filter: string } => ({
filter: `drop-shadow(0 0 ${px / zoom}px ${color})`,
});
/** The quiet look of every surface: a light tint and a fine light outline. */
export const SURFACE = { fill: "rgba(248, 250, 252, 0.1)", strokeOpacity: 0.75 } as const;
+20
View File
@@ -0,0 +1,20 @@
import { Easing, interpolate } from "remotion";
/** Small motion helpers shared by all illustrations. All of them are pure functions of the frame. */
const CLAMP = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
/** 0 before `from`, 1 after `to`, linear in between. */
export const progress = (frame: number, from: number, to: number): number =>
interpolate(frame, [from, to], [0, 1], CLAMP);
export const mix = (from: number, to: number, t: number): number => from + (to - from) * t;
export const inOut = Easing.inOut(Easing.cubic);
export const out = Easing.out(Easing.cubic);
/** Overshoots slightly, for things that pop in. */
export const pop = Easing.out(Easing.back(2.2));
/** `pop` between two frames: 0 before, 1 after. */
export const popAt = (frame: number, from: number, length = 8): number =>
pop(progress(frame, from, from + length));
+311
View File
@@ -0,0 +1,311 @@
import type { FC, ReactNode } from "react";
import { FONT } from "../brand/fonts";
import { useT } from "../content/CopyContext";
import { COLOR, DRAW } from "../brand/tokens";
/** Shared drawing parts for illustrations. Everything is SVG, drawn in stage coordinates. */
export type IconName =
| "cart"
| "invoice"
| "people"
| "mail"
| "check"
| "book"
| "form"
| "chat"
| "bell"
| "tasks";
const ICON_PATHS: Readonly<Record<IconName, ReactNode>> = {
cart: (
<>
<path d="M3 4h2l2.4 10.2a1 1 0 0 0 1 .8h8.9a1 1 0 0 0 1-.8L20 7H6.2" />
<circle cx="9.5" cy="19" r="1.3" />
<circle cx="17" cy="19" r="1.3" />
</>
),
invoice: (
<>
<path d="M7 3h7l4 4v14H7z" />
<path d="M14 3v4h4" />
<path d="M10 12h5" />
<path d="M10 16h5" />
</>
),
people: (
<>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.6 3.1-6 7-6s7 2.4 7 6" />
</>
),
mail: (
<>
<rect x="3" y="6" width="18" height="13" rx="2" />
<path d="m3.5 7.5 8.5 6 8.5-6" />
</>
),
check: (
<>
<circle cx="12" cy="12" r="9" />
<path d="m8 12.5 3 3 5-6" />
</>
),
book: (
<>
<path d="M5 4h11a2 2 0 0 1 2 2v14H7a2 2 0 0 1-2-2z" />
<path d="M5 18a2 2 0 0 1 2-2h11" />
<path d="M9 8h6" />
</>
),
form: (
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M8 9h8" />
<path d="M8 13h8" />
<path d="M8 17h4" />
</>
),
chat: <path d="M4 5h16v11H10l-4 4v-4H4z" />,
bell: (
<>
<path d="M6 16v-5a6 6 0 0 1 12 0v5l1.5 2h-15z" />
<path d="M10 20a2 2 0 0 0 4 0" />
</>
),
tasks: (
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="m8 12 3 3 5-6" />
</>
),
};
/** A line icon from a 24 x 24 grid, centred on (cx, cy). */
export const Icon: FC<{ name: IconName; cx: number; cy: number; size: number; color: string }> = ({
name,
cx,
cy,
size,
color,
}) => (
<g
transform={`translate(${cx - size / 2} ${cy - size / 2}) scale(${size / 24})`}
fill="none"
stroke={color}
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
>
{ICON_PATHS[name]}
</g>
);
export const TILE_SIZE = 150;
/** One tool: a dark rounded tile with an icon and a mono label below. */
export const Tile: FC<{
x: number;
y: number;
icon: IconName;
label: string;
/** Coloured outline, e.g. while someone works in this tool. */
ring?: string;
scale?: number;
opacity?: number;
}> = ({ x, y, icon, label, ring, scale = 1, opacity = 1 }) => {
const centre = TILE_SIZE / 2;
return (
<g transform={`translate(${x} ${y})`} opacity={opacity}>
<g transform={`translate(${centre} ${centre}) scale(${scale}) translate(${-centre} ${-centre})`}>
{ring && (
<rect
x={-10}
y={-10}
width={TILE_SIZE + 20}
height={TILE_SIZE + 20}
rx={42}
fill="none"
stroke={ring}
strokeWidth={4}
style={{ filter: `drop-shadow(0 0 12px ${ring})` }}
/>
)}
<rect width={TILE_SIZE} height={TILE_SIZE} rx={34} fill={DRAW.tint} stroke={DRAW.fg} strokeWidth={2.5} />
<Icon name={icon} cx={centre} cy={centre} size={78} color={DRAW.fg} />
</g>
<text
x={centre}
y={TILE_SIZE + 44}
textAnchor="middle"
fontFamily={FONT.mono}
fontWeight={500}
fontSize={19}
letterSpacing="0.16em"
fill={DRAW.label}
>
{useT()(label).toUpperCase()}
</text>
</g>
);
};
/** Round badge with a tick, centred on (cx, cy). */
export const DoneBadge: FC<{ cx: number; cy: number; r: number; scale?: number }> = ({ cx, cy, r, scale = 1 }) => (
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
<circle r={r} fill={COLOR.done} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${COLOR.done})` }} />
<path
d={`M${-r * 0.42} ${r * 0.04} ${-r * 0.1} ${r * 0.36} ${r * 0.44} ${-r * 0.3}`}
fill="none"
stroke={DRAW.bg}
strokeWidth={r * 0.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
);
/** Round amber badge with one character: something needs attention. */
export const WarnBadge: FC<{ cx: number; cy: number; r: number; scale?: number; char?: string }> = ({
cx,
cy,
r,
scale = 1,
char = "!",
}) => (
<g transform={`translate(${cx} ${cy}) scale(${scale})`}>
<circle r={r} fill={DRAW.mark} style={{ filter: `drop-shadow(0 0 ${r * 0.5}px ${DRAW.mark})` }} />
<text y={r * 0.42} textAnchor="middle" fontFamily={FONT.sans} fontWeight={900} fontSize={r * 1.2} fill={DRAW.bg}>
{char}
</text>
</g>
);
/** Mono caption inside a drawing. */
export const Label: FC<{ x: number; y: number; text: string; anchor?: "start" | "middle" | "end"; color?: string; size?: number }> = ({
x,
y,
text,
anchor = "middle",
color = DRAW.label,
size = 19,
}) => (
<text
x={x}
y={y}
textAnchor={anchor}
fontFamily={FONT.mono}
fontWeight={500}
fontSize={size}
letterSpacing="0.16em"
fill={color}
>
{useT()(text).toUpperCase()}
</text>
);
/** Mouse pointer with its tip at (x, y): stands for a person doing the step by hand. */
export const Cursor: FC<{ x: number; y: number; scale?: number }> = ({ x, y, scale = 1.5 }) => (
<path
transform={`translate(${x} ${y}) scale(${scale})`}
d="M0 0 0 30 8 23 14 36 19 34 13 21 24 21Z"
fill={DRAW.fg}
stroke={DRAW.bg}
strokeWidth={2.5}
strokeLinejoin="round"
/>
);
/** A piece of data: dark pill with binary digits. */
export const DataChip: FC<{ x: number; y: number; width?: number; digits?: string; color?: string }> = ({
x,
y,
width = 150,
digits = "0110",
color = DRAW.fg,
}) => (
<g transform={`translate(${x} ${y})`}>
<rect width={width} height={44} rx={22} fill={color} />
<text
x={width / 2}
y={30}
textAnchor="middle"
fontFamily={FONT.mono}
fontWeight={700}
fontSize={22}
letterSpacing="0.2em"
fill={DRAW.bg}
>
{digits}
</text>
</g>
);
/** An app window: white card with a title bar. Children are drawn in its coordinates. */
export const AppWindow: FC<{
x: number;
y: number;
width: number;
height: number;
title: string;
children?: ReactNode;
}> = ({ x, y, width, height, title, children }) => (
<g transform={`translate(${x} ${y})`}>
<rect width={width} height={height} rx={30} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
<path d={`M0 64H${width}`} stroke={DRAW.rim} strokeWidth={2} />
{[30, 56, 82].map((cx) => (
<circle key={cx} cx={cx} cy={33} r={8} fill={DRAW.soft} />
))}
<text
x={width - 28}
y={41}
textAnchor="end"
fontFamily={FONT.mono}
fontWeight={500}
fontSize={19}
letterSpacing="0.16em"
fill={DRAW.label}
>
{useT()(title).toUpperCase()}
</text>
{children}
</g>
);
/** A box for one day of a week or a countdown, centred on cx. */
export const DayBox: FC<{
cx: number;
y: number;
width: number;
label: string;
filled: boolean;
scale?: number;
}> = ({ cx, y, width, label, filled, scale = 1 }) => (
<g transform={`translate(${cx} ${y + 55}) scale(${scale})`}>
<rect
x={-width / 2}
y={-55}
width={width}
height={110}
rx={26}
fill={filled ? DRAW.fg : DRAW.glass}
stroke={filled ? DRAW.fg : DRAW.rim}
strokeWidth={2.5}
/>
<Label x={0} y={8} text={label} color={filled ? DRAW.bg : DRAW.fg} size={24} />
</g>
);
/** A sheet of paper. Children are drawn in its coordinates. */
export const Page: FC<{ x: number; y: number; width: number; height: number; children?: ReactNode }> = ({
x,
y,
width,
height,
children,
}) => (
<g transform={`translate(${x} ${y})`}>
<rect width={width} height={height} rx={24} fill={DRAW.glass} stroke={DRAW.fg} strokeWidth={2.5} />
{children}
</g>
);
+259
View File
@@ -0,0 +1,259 @@
import type { FC, ReactNode } from "react";
import { useCurrentFrame } from "remotion";
import { COLOR, DRAW } from "../brand/tokens";
import { STAGE_WIDTH } from "../slides/SlideDeck";
import { inOut, mix, out, popAt, progress } from "./motion";
import { AppWindow, Cursor, DataChip, DoneBadge, Icon, TILE_SIZE, Tile, type IconName } from "./parts";
/** Scenes that several videos share. Each is one slide's illustration, drawn from its frame 0. */
export const SCENE_HEIGHT = 520;
export const CTA_SCENE_HEIGHT = 300;
export type Tool = { readonly icon: IconName; readonly label: string };
/** Scenes with a row of tools expect exactly four of them. */
export type Tools = readonly [Tool, Tool, Tool, Tool];
const TOOL_COUNT = 4;
const TOOL_GAP = (STAGE_WIDTH - TOOL_COUNT * TILE_SIZE) / (TOOL_COUNT - 1);
export const toolX = (index: number): number => index * (TILE_SIZE + TOOL_GAP);
export const ROW_Y = 185;
export const Canvas: FC<{ height?: number; children: ReactNode }> = ({ height = SCENE_HEIGHT, children }) => (
<svg width={STAGE_WIDTH} height={height} viewBox={`0 0 ${STAGE_WIDTH} ${height}`} style={{ overflow: "visible" }}>
{children}
</svg>
);
const CHIP_DIGITS = ["0110", "1001", "1101"] as const;
/** Values carried by hand from one window into another, again and again. */
export const CarryScene: FC<{ fromTitle: string; toTitle: string }> = ({ fromTitle, toTitle }) => {
const frame = useCurrentFrame();
const perChip = 25;
const rowY = (row: number): number => 166 + row * 92;
const current = Math.min(CHIP_DIGITS.length - 1, Math.floor(frame / perChip));
const chipAt = (row: number): { x: number; y: number } => {
const t = row < current ? 1 : row > current ? 0 : inOut(progress(frame, row * perChip + 4, row * perChip + 20));
return { x: mix(34, 472 + 38, t), y: rowY(row) - Math.sin(Math.PI * t) * 46 };
};
const held = chipAt(current);
return (
<Canvas>
<AppWindow x={0} y={60} width={400} height={400} title={fromTitle}>
{CHIP_DIGITS.map((digits, row) => (
<rect key={digits} x={200} y={rowY(row) - 46} width={160} height={16} rx={8} fill={DRAW.soft} />
))}
</AppWindow>
<AppWindow x={472} y={60} width={400} height={400} title={toTitle}>
{CHIP_DIGITS.map((digits, row) => (
<rect
key={digits}
x={30}
y={rowY(row) - 66}
width={340}
height={56}
rx={28}
fill="none"
stroke={DRAW.rim}
strokeWidth={2}
strokeDasharray="10 10"
/>
))}
</AppWindow>
{CHIP_DIGITS.map((digits, row) => {
const chip = chipAt(row);
return <DataChip key={digits} x={chip.x} y={chip.y} digits={digits} />;
})}
<Cursor x={held.x + 112} y={held.y + 26} />
</Canvas>
);
};
/** Four separate tools, and a person hopping between them with the data. */
export const HopScene: FC<{ tools: Tools }> = ({ tools }) => {
const frame = useCurrentFrame();
const perTool = 15;
const active = Math.min(TOOL_COUNT - 1, Math.floor(frame / perTool));
const hop = active === 0 ? 1 : inOut(progress(frame, active * perTool, active * perTool + 9));
const x = mix(toolX(Math.max(0, active - 1)), toolX(active), hop) + 78;
const y = ROW_Y + TILE_SIZE + 70 - Math.sin(Math.PI * hop) * 40;
return (
<Canvas>
{tools.map((tool, index) => (
<Tile key={tool.label} x={toolX(index)} y={ROW_Y} {...tool} ring={index === active ? COLOR.active : undefined} />
))}
<DataChip x={x + 34} y={y + 26} width={110} />
<Cursor x={x} y={y} />
</Canvas>
);
};
const CARD = { width: 300, height: 66, step: 46, count: 9, x: 60, base: 440 } as const;
/** One item of work waiting to be handled. */
export const WorkCard: FC<{
x: number;
y: number;
icon: IconName;
tilt?: number;
opacity?: number;
done?: boolean;
width?: number;
}> = ({ x, y, icon, tilt = 0, opacity = 1, done = false, width = CARD.width }) => (
<g transform={`translate(${x} ${y}) rotate(${tilt} ${width / 2} ${CARD.height / 2})`} opacity={opacity}>
<rect width={width} height={CARD.height} rx={20} fill={DRAW.glass} stroke={done ? COLOR.done : DRAW.fg} strokeWidth={2.5} />
<Icon name={icon} cx={40} cy={CARD.height / 2} size={34} color={done ? COLOR.done : DRAW.fg} />
<rect x={76} y={CARD.height / 2 - 8} width={width / 2} height={16} rx={8} fill={DRAW.soft} />
</g>
);
const cardTilt = (index: number): number => (index % 2 === 0 ? -2.5 : 2);
const cardY = (index: number): number => CARD.base - index * CARD.step;
/** Work arrives faster than one person can handle it. */
export const PileScene: FC<{ icon: IconName }> = ({ icon }) => {
const frame = useCurrentFrame();
const every = 5;
return (
<Canvas>
{Array.from({ length: CARD.count }, (_, index) => {
const fall = out(progress(frame, index * every, index * every + 8));
return fall === 0 ? null : (
<WorkCard key={index} x={CARD.x} y={mix(-120, cardY(index), fall)} icon={icon} tilt={cardTilt(index)} opacity={fall} />
);
})}
<Tile x={600} y={ROW_Y} icon="people" label="One person" ring={COLOR.active} />
<Cursor x={690 + Math.cos(frame / 5) * 14} y={ROW_Y + TILE_SIZE / 2 + 40 + Math.sin(frame / 5) * 14} />
</Canvas>
);
};
/** The pile from PileScene clears itself. */
export const ClearedScene: FC<{ icon: IconName }> = ({ icon }) => {
const frame = useCurrentFrame();
const every = 4;
return (
<Canvas>
<DoneBadge cx={STAGE_WIDTH / 2} cy={250} r={120} scale={popAt(frame, 36, 14)} />
{Array.from({ length: CARD.count }, (_, index) => {
const order = CARD.count - 1 - index;
const leave = inOut(progress(frame, 4 + order * every, 14 + order * every));
return leave === 1 ? null : (
<WorkCard
key={index}
x={CARD.x + leave * 620}
y={cardY(index)}
icon={icon}
tilt={cardTilt(index)}
opacity={1 - leave}
done={leave > 0}
/>
);
})}
</Canvas>
);
};
const PACKET_TRAVEL_FRAMES = 27;
const PACKET_EVERY_FRAMES = 9;
/** Four tools in a row, connected, with work travelling through on its own. */
export const FlowRow: FC<{
tools: Tools;
y: number;
/** 0..1: how far the link after tool `index` is drawn. */
connected: (index: number) => number;
/** Frame at which work starts to flow, or null while the tools are only being connected. */
flowFrom: number | null;
}> = ({ tools, y, connected, flowFrom }) => {
const frame = useCurrentFrame();
const centreY = y + TILE_SIZE / 2;
const firstCentre = toolX(0) + TILE_SIZE / 2;
const lastCentre = toolX(TOOL_COUNT - 1) + TILE_SIZE / 2;
const flowing = flowFrom === null ? -1 : frame - flowFrom;
const packets =
flowing < 0 ? [] : Array.from({ length: Math.floor(flowing / PACKET_EVERY_FRAMES) + 1 }, (_, n) => n);
const passFrame = (index: number): number => (PACKET_TRAVEL_FRAMES * index) / (TOOL_COUNT - 1);
return (
<>
{tools.slice(1).map((tool, index) => {
const from = toolX(index) + TILE_SIZE;
const drawn = connected(index);
const tip = mix(from, toolX(index + 1), drawn);
return drawn === 0 ? null : (
<g key={tool.label}>
<path d={`M${from} ${centreY}H${tip}`} stroke={DRAW.fg} strokeWidth={2.5} />
<circle cx={tip} cy={centreY} r={6} fill={DRAW.fg} />
</g>
);
})}
{packets.map((n) => {
const t = (flowing - n * PACKET_EVERY_FRAMES) / PACKET_TRAVEL_FRAMES;
return t > 1 ? null : (
<circle key={n} cx={mix(firstCentre, lastCentre, t)} cy={centreY} r={11}
fill={DRAW.mark}
stroke={DRAW.mark}
strokeOpacity={0.28}
strokeWidth={16}
style={{ filter: `drop-shadow(0 0 10px ${DRAW.mark})` }}
/>
);
})}
{tools.map((tool, index) => {
const since = flowing - passFrame(index);
const pulse = since < 0 ? 0 : 1 - (since % PACKET_EVERY_FRAMES) / PACKET_EVERY_FRAMES;
return (
<g key={tool.label}>
<Tile x={toolX(index)} y={y} {...tool} scale={1 + pulse * 0.05} />
{since >= 0 && (
<DoneBadge cx={toolX(index) + TILE_SIZE - 8} cy={y + 8} r={26} scale={popAt(since, 0) * (1 + pulse * 0.12)} />
)}
</g>
);
})}
</>
);
};
/** The tools get connected, one link after the other. */
export const ConnectScene: FC<{ tools: Tools }> = ({ tools }) => {
const frame = useCurrentFrame();
return (
<Canvas>
<FlowRow
tools={tools}
y={ROW_Y}
connected={(index) => inOut(progress(frame, 8 + index * 12, 20 + index * 12))}
flowFrom={null}
/>
</Canvas>
);
};
const RUNS_FLOW_FROM = 4;
const FINISHED_SLOTS = 9;
/** Work runs through all four tools by itself; finished items collect below. */
export const RunsScene: FC<{ tools: Tools }> = ({ tools }) => {
const frame = useCurrentFrame();
const firstArrival = RUNS_FLOW_FROM + PACKET_TRAVEL_FRAMES;
return (
<Canvas>
<FlowRow tools={tools} y={110} connected={() => 1} flowFrom={RUNS_FLOW_FROM} />
{Array.from({ length: FINISHED_SLOTS }, (_, n) => {
const arrival = firstArrival + n * PACKET_EVERY_FRAMES;
return frame < arrival ? null : (
<DoneBadge key={n} cx={36 + n * 100} cy={420} r={30} scale={popAt(frame, arrival)} />
);
})}
</Canvas>
);
};
/** The connected tools, still running: sits above the call to action. */
export const RunningScene: FC<{ tools: Tools }> = ({ tools }) => (
<Canvas height={CTA_SCENE_HEIGHT}>
<FlowRow tools={tools} y={64} connected={() => 1} flowFrom={-60} />
</Canvas>
);
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { Root } from "./Root";
registerRoot(Root);
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { outlinePoint, sparkAt, type Box } from "./aura";
const BOX: Box = { width: 400, height: 200 };
const LOOP = 300;
describe("outlinePoint", () => {
it("starts at the top left corner and walks clockwise", () => {
expect(outlinePoint(0, BOX)).toEqual({ x: 0, y: 0, nx: 0, ny: -1 });
// Perimeter is 1200: a quarter of it is 300 px along the top edge.
expect(outlinePoint(0.25, BOX)).toEqual({ x: 300, y: 0, nx: 0, ny: -1 });
});
it("gives the right edge an outward normal pointing right", () => {
// 450 px: 400 along the top, 50 down the right edge.
expect(outlinePoint(450 / 1200, BOX)).toEqual({ x: 400, y: expect.closeTo(50, 6), nx: 1, ny: 0 });
});
it("walks the bottom edge from right to left and the left edge upwards", () => {
expect(outlinePoint(700 / 1200, BOX)).toEqual({ x: expect.closeTo(300, 6), y: 200, nx: 0, ny: 1 });
expect(outlinePoint(1100 / 1200, BOX)).toEqual({ x: 0, y: expect.closeTo(100, 6), nx: -1, ny: 0 });
});
it("wraps positions outside 0..1", () => {
expect(outlinePoint(1.25, BOX).x).toBeCloseTo(300, 6);
expect(outlinePoint(-0.75, BOX).x).toBeCloseTo(300, 6);
});
});
describe("sparkAt", () => {
it("is deterministic", () => {
expect(sparkAt(7, 120, LOOP, BOX)).toEqual(sparkAt(7, 120, LOOP, BOX));
});
it("is the same on the frame after the loop ends as on frame 0", () => {
for (const index of [0, 1, 2, 17, 40]) {
expect(sparkAt(index, LOOP, LOOP, BOX)).toEqual(sparkAt(index, 0, LOOP, BOX));
}
});
it("hides a spark outside its life and shows it inside", () => {
const visibleFrames = Array.from({ length: LOOP }, (_, frame) => sparkAt(3, frame, LOOP, BOX)).filter(
(spark) => spark !== null,
);
expect(visibleFrames.length).toBeGreaterThan(0);
expect(visibleFrames.length).toBeLessThan(LOOP);
});
it("fades in and out: opacity stays within 0..1 and digits are 0 or 1", () => {
for (let frame = 0; frame < LOOP; frame++) {
const spark = sparkAt(5, frame, LOOP, BOX);
if (spark === null) continue;
expect(spark.opacity).toBeGreaterThanOrEqual(0);
expect(spark.opacity).toBeLessThanOrEqual(1);
expect(["0", "1"]).toContain(spark.digit);
}
});
it("moves away from the outline as it ages", () => {
const frames = Array.from({ length: LOOP }, (_, frame) => sparkAt(9, frame, LOOP, BOX));
const first = frames.findIndex((spark) => spark !== null);
const distance = (frame: number): number => {
const spark = frames[frame];
if (!spark) throw new Error("spark expected");
const outsideX = Math.max(0, -spark.x, spark.x - BOX.width);
const outsideY = Math.max(0, -spark.y, spark.y - BOX.height);
return Math.hypot(outsideX, outsideY);
};
// Frame `first` can be mid-life if the spark was born before the loop wrapped; pick a run of two frames.
const young = frames.findIndex((spark, frame) => spark !== null && frames[frame + 6] !== null && frame >= first);
expect(distance(young + 6)).toBeGreaterThan(distance(young));
});
it("gives different sparks different places", () => {
const places = new Set(
Array.from({ length: 60 }, (_, index) => sparkAt(index, 150, LOOP, BOX))
.filter((spark) => spark !== null)
.map((spark) => `${Math.round(spark.x)}:${Math.round(spark.y)}`),
);
expect(places.size).toBeGreaterThan(3);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* The website's BitAura (energy along the outline of an element, digits drifting off it), as pure
* functions of the frame number. The website simulates it with random particles; here every spark's
* whole life is computed from its index, so each frame can be rendered on its own and the motion
* repeats exactly when a video loops.
*/
export type Box = { readonly width: number; readonly height: number };
export type OutlinePoint = {
readonly x: number;
readonly y: number;
/** Outward normal of the edge the point lies on. */
readonly nx: number;
readonly ny: number;
};
export type Spark = {
readonly x: number;
readonly y: number;
readonly opacity: number;
readonly digit: "0" | "1";
readonly large: boolean;
};
/** How often the light travels round the element during one loop of the video. */
export const AURA_LAPS = 2;
const MIN_LIFE_FRAMES = 50;
const EXTRA_LIFE_FRAMES = 60;
const MIN_SPEED = 0.35;
const EXTRA_SPEED = 0.75;
const MAX_SLIDE = 0.3;
const SCATTER = 0.14;
const LARGE_SHARE = 0.22;
const FLIP_EVERY_FRAMES = 33;
const fraction = (value: number): number => value - Math.floor(value);
/** Point on the outline of a box, walking clockwise from the top left corner. `at` wraps at 1. */
export const outlinePoint = (at: number, { width, height }: Box): OutlinePoint => {
const distance = fraction(at) * 2 * (width + height);
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
if (distance < width + height) return { x: width, y: distance - width, nx: 1, ny: 0 };
if (distance < 2 * width + height) return { x: 2 * width + height - distance, y: height, nx: 0, ny: 1 };
return { x: 0, y: 2 * (width + height) - distance, nx: -1, ny: 0 };
};
/** A stable pseudo-random number in 0..1 for a spark and one of its properties. */
const random = (index: number, property: number): number => {
const hash = Math.imul(index + 1, 2654435761) ^ Math.imul(property + 1, 2246822519);
return ((Math.imul(hash ^ (hash >>> 15), 3266489917) >>> 0) % 100000) / 100000;
};
/** Where the light is on the outline at a frame: it circles AURA_LAPS times per loop. */
export const cometAt = (frame: number, loopFrames: number): number =>
fraction((frame / loopFrames) * AURA_LAPS);
/**
* Spark number `index` at `frame`, or null while it is not alive. It is born where the light is at
* that moment, drifts away from the edge and fades. `loopFrames` is the length of the video.
*/
export const sparkAt = (index: number, frame: number, loopFrames: number, box: Box): Spark | null => {
const birth = Math.floor(random(index, 0) * loopFrames);
const life = Math.min(loopFrames, MIN_LIFE_FRAMES + random(index, 1) * EXTRA_LIFE_FRAMES);
const age = (((frame - birth) % loopFrames) + loopFrames) % loopFrames;
if (age >= life) return null;
const second = index % 2 === 1 ? 0.5 : 0;
const origin = outlinePoint(cometAt(birth, loopFrames) + second + (random(index, 2) - 0.5) * SCATTER, box);
const travelled = (MIN_SPEED + random(index, 3) * EXTRA_SPEED) * age;
const slid = (random(index, 4) - 0.5) * 2 * MAX_SLIDE * age;
const seed = random(index, 5);
return {
x: origin.x + origin.nx * travelled - origin.ny * slid,
y: origin.y + origin.ny * travelled + origin.nx * slid,
opacity: Math.sin((Math.PI * age) / life),
digit: Math.floor(age / FLIP_EVERY_FRAMES + seed * 10) % 2 === 0 ? "1" : "0",
large: random(index, 6) < LARGE_SHARE,
};
};
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { bitString, decodeText } from "./bits";
const isBinary = (s: string): boolean => /^[01]*$/.test(s);
describe("decodeText", () => {
it("shows only digits for letters and numbers at progress 0, keeps punctuation and spaces", () => {
const out = decodeText("Hi, 2 you!", 0, 0);
expect(out).toHaveLength("Hi, 2 you!".length);
expect(out.replace(/[^A-Za-z2-9]/g, "")).toBe("");
expect(out[2]).toBe(",");
expect(out[3]).toBe(" ");
expect(out[9]).toBe("!");
});
it("returns the original text at progress 1", () => {
expect(decodeText("Concept to code", 1, 7)).toBe("Concept to code");
});
it("resolves left to right: at 0.5 the first half is readable, the rest still digits", () => {
const out = decodeText("ABCDEFGH", 0.5, 3);
expect(out.slice(0, 4)).toBe("ABCD");
expect(isBinary(out.slice(4))).toBe(true);
});
it("clamps progress outside 0..1", () => {
expect(decodeText("Abc", 2, 0)).toBe("Abc");
expect(isBinary(decodeText("Abc", -1, 0))).toBe(true);
});
it("is deterministic for the same tick and varies with the tick", () => {
const text = "WEB APPS & WEBSITES";
expect(decodeText(text, 0, 5)).toBe(decodeText(text, 0, 5));
const ticks = new Set(
Array.from({ length: 10 }, (_, tick) => decodeText(text, 0, tick)),
);
expect(ticks.size).toBeGreaterThan(1);
});
it("handles the empty string", () => {
expect(decodeText("", 0.5, 0)).toBe("");
});
});
describe("bitString", () => {
it("has the requested length and only 0 and 1", () => {
const out = bitString(40, 3);
expect(out).toHaveLength(40);
expect(isBinary(out)).toBe(true);
});
it("is deterministic and changes with the tick", () => {
expect(bitString(40, 3)).toBe(bitString(40, 3));
expect(bitString(40, 3)).not.toBe(bitString(40, 4));
});
it("is empty for length 0", () => {
expect(bitString(0, 1)).toBe("");
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Pure, frame-driven binary effects. Remotion renders frame by frame, so nothing here
* uses timers or randomness: the same inputs always give the same output.
*/
const HASH_MULTIPLIER = 2654435761;
const TICK_MULTIPLIER = 40503;
const LETTER_OR_DIGIT = /[A-Za-z0-9]/;
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
/** A stable pseudo-random bit for a position at a given tick. */
const bitAt = (index: number, tick: number): "0" | "1" => {
const hash = Math.imul(index + 1, HASH_MULTIPLIER) ^ Math.imul(tick + 1, TICK_MULTIPLIER);
return ((hash >>> 7) & 1) === 0 ? "0" : "1";
};
/**
* Text resolving from digits, left to right. Letters and numbers not yet reached show
* as 0 or 1; spaces and punctuation stay put so the shape of the text is readable.
*/
export const decodeText = (text: string, progress: number, tick: number): string => {
const chars = Array.from(text);
const revealed = Math.floor(clamp01(progress) * chars.length);
return chars
.map((char, index) =>
index < revealed || !LETTER_OR_DIGIT.test(char) ? char : bitAt(index, tick),
)
.join("");
};
/** A string of `length` binary digits for a given tick. */
export const bitString = (length: number, tick: number): string =>
Array.from({ length }, (_, index) => bitAt(index, tick)).join("");
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { curtainEdge, curtainPolygon } from "./curtain";
const ROWS = Array.from({ length: 30 }, (_, row) => row);
describe("curtainEdge", () => {
it("is fully closed at progress 0 and fully open at progress 1, in every row", () => {
for (const row of ROWS) {
expect(curtainEdge(row, 0)).toBe(0);
expect(curtainEdge(row, 1)).toBe(1);
}
});
it("never moves backwards as progress grows", () => {
for (const row of ROWS) {
let last = 0;
for (let step = 0; step <= 20; step++) {
const edge = curtainEdge(row, step / 20);
expect(edge).toBeGreaterThanOrEqual(last);
last = edge;
}
}
});
it("is ragged: rows are at different places half way through", () => {
const places = new Set(ROWS.map((row) => curtainEdge(row, 0.5).toFixed(3)));
expect(places.size).toBeGreaterThan(10);
});
it("stays within 0..1 and clamps progress outside 0..1", () => {
expect(curtainEdge(3, -1)).toBe(0);
expect(curtainEdge(3, 2)).toBe(1);
});
it("is deterministic per row", () => {
expect(curtainEdge(7, 0.4)).toBe(curtainEdge(7, 0.4));
});
});
describe("curtainPolygon", () => {
const box = { width: 200, height: 40, cell: 20 };
it("covers nothing at progress 0 and the whole box at progress 1 (behind the edge)", () => {
expect(curtainPolygon("behind", 0, box)).toBe("polygon(0px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 0px 40px)");
expect(curtainPolygon("behind", 1, box)).toBe(
"polygon(0px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 0px 40px)",
);
});
it("is the complement ahead of the edge", () => {
expect(curtainPolygon("ahead", 1, box)).toBe(
"polygon(200px 0px, 200px 0px, 200px 20px, 200px 20px, 200px 40px, 200px 40px)",
);
expect(curtainPolygon("ahead", 0, box)).toBe(
"polygon(200px 0px, 0px 0px, 0px 20px, 0px 20px, 0px 40px, 200px 40px)",
);
});
it("adds a row for a height that is not a multiple of the cell", () => {
const points = curtainPolygon("behind", 1, { width: 100, height: 50, cell: 20 }).split(",");
// Two corner points plus two per row, for three rows.
expect(points).toHaveLength(8);
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* The website's BitCurtain (the language switch transition): a front of binary digits with a ragged
* edge that sweeps from left to right. Here it changes slides. Pure functions of the progress.
*/
/** How ragged the edge is, as a share of the width. */
const SPREAD = 0.24;
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
/** A fixed pseudo-random number in 0..1 per row, so the edge keeps its shape while it moves. */
const rowLag = (row: number): number => {
const x = Math.sin((row + 1) * 12.9898) * 43758.5453;
return x - Math.floor(x);
};
/** Where the edge is in a row, as a share of the width: 0 before the sweep, 1 after it. */
export const curtainEdge = (row: number, progress: number): number =>
clamp01(clamp01(progress) * (1 + SPREAD) - rowLag(row) * SPREAD);
export type CurtainBox = { readonly width: number; readonly height: number; readonly cell: number };
/**
* CSS `clip-path` for one side of the edge: `behind` is what the front has already passed
* (left of it), `ahead` is what it has not reached yet.
*/
export const curtainPolygon = (side: "behind" | "ahead", progress: number, box: CurtainBox): string => {
const outer = side === "behind" ? 0 : box.width;
const rows = Math.ceil(box.height / box.cell);
const steps = Array.from({ length: rows }, (_, row) => {
const x = curtainEdge(row, progress) * box.width;
return [`${x}px ${row * box.cell}px`, `${x}px ${Math.min(box.height, (row + 1) * box.cell)}px`];
}).flat();
return `polygon(${[`${outer}px 0px`, ...steps, `${outer}px ${box.height}px`].join(", ")})`;
};
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { hopAt } from "./hop";
const A = { x: 100, y: 300 };
const B = { x: 300, y: 300 };
const C = { x: 700, y: 300 };
const FRAMES = 10;
const ARC = 0.5;
describe("hopAt", () => {
it("rests on the first stop at time 0 and on the next one a hop later", () => {
expect(hopAt(0, [A, B, C], FRAMES, ARC)).toEqual(A);
expect(hopAt(10, [A, B, C], FRAMES, ARC)).toEqual(B);
expect(hopAt(20, [A, B, C], FRAMES, ARC)).toEqual(C);
});
it("is half way across and highest in the middle of a hop", () => {
// A to B is 200 px: the arc rises by half of that.
const mid = hopAt(5, [A, B, C], FRAMES, ARC);
expect(mid.x).toBeCloseTo(200, 6);
expect(mid.y).toBeCloseTo(200, 6);
});
it("jumps higher on a longer hop", () => {
// B to C is 400 px.
expect(hopAt(15, [A, B, C], FRAMES, ARC).y).toBeCloseTo(100, 6);
});
it("returns from the last stop to the first and starts again", () => {
expect(hopAt(30, [A, B, C], FRAMES, ARC)).toEqual(A);
expect(hopAt(40, [A, B, C], FRAMES, ARC)).toEqual(B);
expect(hopAt(25, [A, B, C], FRAMES, ARC).x).toBeCloseTo(400, 6);
});
it("works backwards in time, so a loop can end where it starts", () => {
expect(hopAt(-10, [A, B, C], FRAMES, ARC)).toEqual(C);
expect(hopAt(-30, [A, B, C], FRAMES, ARC)).toEqual(A);
});
it("eases: slower near a stop than in the middle of a hop", () => {
const nearStop = hopAt(1, [A, B], FRAMES, ARC).x - A.x;
const inFlight = hopAt(5, [A, B], FRAMES, ARC).x - hopAt(4, [A, B], FRAMES, ARC).x;
expect(nearStop).toBeLessThan(inFlight);
});
it("stays put with a single stop", () => {
expect(hopAt(7, [A], FRAMES, ARC)).toEqual(A);
});
});
+24
View File
@@ -0,0 +1,24 @@
/** Something that hops from stop to stop in an arc, round and round. A pure function of time. */
export type Point = { readonly x: number; readonly y: number };
const easeInOut = (t: number): number => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
const wrap = (index: number, length: number): number => ((index % length) + length) % length;
/**
* Where the hopper is at `time`. It rests on `stops[0]` at time 0, reaches the next stop every
* `framesPerHop`, and returns from the last stop to the first. The arc of a hop rises by
* `arcRatio` times the distance covered. Negative times are fine.
*/
export const hopAt = (time: number, stops: readonly Point[], framesPerHop: number, arcRatio: number): Point => {
const hop = Math.floor(time / framesPerHop);
const from = stops[wrap(hop, stops.length)];
const to = stops[wrap(hop + 1, stops.length)];
if (!from || !to) throw new Error("hopAt needs at least one stop");
const t = easeInOut(time / framesPerHop - hop);
const distance = Math.hypot(to.x - from.x, to.y - from.y);
return {
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t - Math.sin(Math.PI * t) * distance * arcRatio,
};
};
+32
View File
@@ -0,0 +1,32 @@
import type { FC } from "react";
import { BrandFrame } from "../brand/BrandFrame";
import { CopyProvider } from "../content/CopyContext";
import { COPY, type Lang, type VideoId } from "../content/videos";
import { SlideDeck, deckDuration, type Slide } from "./SlideDeck";
/**
* A video about one service, and the id under which its texts are kept. It is either a deck of
* slides or a film: one continuous shot that brings its own stage.
*/
export type VideoEntry =
| { readonly id: VideoId; readonly slides: readonly Slide[] }
| { readonly id: VideoId; readonly Film: FC; readonly durationInFrames: number };
export const videoDuration = (video: VideoEntry): number =>
"Film" in video ? video.durationInFrames : deckDuration(video.slides);
/** One service video in one language: brand frame, looping slide deck, texts from the copy file. */
export const ServiceVideo: FC<{ video: VideoEntry; lang: Lang }> = ({ video, lang }) => {
const copy = COPY[video.id][lang];
return (
<CopyProvider value={{ lang, copy }}>
{"Film" in video ? (
<video.Film />
) : (
<BrandFrame>
<SlideDeck slides={video.slides} />
</BrandFrame>
)}
</CopyProvider>
);
};
+306
View File
@@ -0,0 +1,306 @@
import type { CSSProperties, FC, ReactNode } from "react";
import { Easing, Freeze, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
import { BitWipe } from "../bits/BitWipe";
import { FRAME } from "../brand/BrandFrame";
import { FONT } from "../brand/fonts";
import { Panel } from "../brand/Panel";
import { COLOR, DRAW } from "../brand/tokens";
import { useCopy } from "../content/CopyContext";
import { bitString } from "../lib/bits";
import { curtainEdge, curtainPolygon } from "../lib/curtain";
/** Illustrations are drawn in a box this wide; it is scaled down to fit into the panel. */
export const STAGE_WIDTH = 872;
const DEFAULT_ILLUSTRATION_HEIGHT = 520;
const ILLUSTRATION_SCALE = 0.9;
const PANEL_HEIGHT = 600;
/** Height the panel shrinks to on a slide with extra content below the headline (the button). */
const PANEL_HEIGHT_WITH_AFTER = 454;
const GAP = 60;
const TEXT_HEIGHT = 200;
const TALL_STAGE_HEIGHT = PANEL_HEIGHT + GAP + TEXT_HEIGHT;
const WIDE_TEXT_WIDTH = 620;
const WIDE_GAP = 110;
const WIDE_STAGE = { width: STAGE_WIDTH + WIDE_GAP + WIDE_TEXT_WIDTH, height: PANEL_HEIGHT };
const ENTER_FRAMES = 10;
const EXIT_FRAMES = 8;
/** Length of the digit curtain that changes the picture. */
const WIPE_FRAMES = 16;
const CURTAIN_CELL = 24;
const CURTAIN_TRAIL = 9;
const CURTAIN_LEAD = 2;
const MAX_HEADLINE_SIZE = 100;
/** Average width of a character of the headline font, as a share of the font size. */
const CHAR_WIDTH = 0.53;
export type Slide = {
/** Also the key of the slide's headline in the video's copy. */
readonly id: string;
readonly durationInFrames: number;
/** Draws into a box of STAGE_WIDTH x illustrationHeight. Its frame 0 is the slide's first frame. */
readonly Illustration: FC;
readonly illustrationHeight?: number;
/** Extra content below the headline, e.g. the button on the last slide. */
readonly after?: ReactNode;
};
type Box = { readonly width: number; readonly height: number };
const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
const easeOut = Easing.out(Easing.cubic);
const easeInOut = Easing.inOut(Easing.cubic);
/** How far the curtain is, `since` frames into a wipe. Complete on the wipe's last frame. */
const wipeProgress = (since: number): number =>
easeInOut(interpolate(since, [-1, WIPE_FRAMES - 1], [0, 1], clampOpts));
/** Largest headline size at which the longest line still fits the given width. */
export const headlineSize = (lines: readonly string[], width: number): number => {
const longest = Math.max(1, ...lines.map((line) => line.length));
return Math.min(MAX_HEADLINE_SIZE, Math.floor(width / (longest * CHAR_WIDTH)));
};
/**
* The drawing of one slide, centred in the panel. The curtain reveals it from the left at its
* start (`wipeIn`) and takes it away from the left from frame `wipeOutAt` on.
*/
const Picture: FC<{ slide: Slide; box: Box; wipeIn: boolean; wipeOutAt: number | null }> = ({
slide,
box,
wipeIn,
wipeOutAt,
}) => {
const frame = useCurrentFrame();
const { Illustration } = slide;
const curtain = { ...box, cell: CURTAIN_CELL };
const clipPath =
wipeOutAt !== null && frame >= wipeOutAt
? curtainPolygon("ahead", wipeProgress(frame - wipeOutAt), curtain)
: wipeIn && frame < WIPE_FRAMES
? curtainPolygon("behind", wipeProgress(frame), curtain)
: undefined;
return (
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", clipPath }}>
<div
style={{
width: STAGE_WIDTH,
height: slide.illustrationHeight ?? DEFAULT_ILLUSTRATION_HEIGHT,
flexShrink: 0,
transform: `scale(${ILLUSTRATION_SCALE})`,
}}
>
<Illustration />
</div>
</div>
);
};
/** The moving edge of the curtain: bright digits at the front, a fading trail behind it. */
const CurtainFront: FC<{ box: Box }> = ({ box }) => {
const frame = useCurrentFrame();
const progress = wipeProgress(frame);
const rows = Math.ceil(box.height / CURTAIN_CELL);
return (
<svg width={box.width} height={box.height} style={{ position: "absolute", inset: 0 }}>
{Array.from({ length: rows }, (_, row) => {
const x = curtainEdge(row, progress) * box.width;
if (x <= 0 || x >= box.width) return null;
const digits = bitString(CURTAIN_TRAIL + CURTAIN_LEAD, frame * 7 + row * 13);
return Array.from(digits, (digit, index) => {
// k counts cells from the edge back into what is already revealed; negative is ahead of it.
const k = index - CURTAIN_LEAD;
return (
<text
key={`${row}-${index}`}
x={x - (k + 0.5) * CURTAIN_CELL}
y={row * CURTAIN_CELL + CURTAIN_CELL / 2}
textAnchor="middle"
dominantBaseline="middle"
fontFamily={FONT.mono}
fontWeight={700}
fontSize={18}
fill={k < 0 ? DRAW.label : k < 2 ? DRAW.mark : DRAW.fg}
opacity={k < 0 ? 0.5 : (1 - k / CURTAIN_TRAIL) * 0.9}
>
{digit}
</text>
);
});
})}
</svg>
);
};
/** All pictures in turn, changed by the curtain, and the first one again at the very end. */
const Pictures: FC<{ slides: readonly Slide[]; starts: readonly number[]; total: number; box: Box }> = ({
slides,
starts,
total,
box,
}) => {
const first = slides[0];
const last = slides.length - 1;
return (
<>
{slides.map((slide, index) => {
// Every picture keeps playing while the next one is wiped in; the last one is wiped out within its time.
const wipeOutAt = index === last ? slide.durationInFrames - WIPE_FRAMES : slide.durationInFrames;
return (
<Sequence
key={slide.id}
from={starts[index]}
durationInFrames={index === last ? slide.durationInFrames : slide.durationInFrames + WIPE_FRAMES}
layout="none"
>
<Picture slide={slide} box={box} wipeIn={index > 0} wipeOutAt={wipeOutAt} />
</Sequence>
);
})}
{first && (
<Sequence from={total - WIPE_FRAMES} durationInFrames={WIPE_FRAMES} layout="none">
<LoopPicture slide={first} box={box} />
</Sequence>
)}
{[...starts.slice(1), total - WIPE_FRAMES].map((from) => (
<Sequence key={from} from={from} durationInFrames={WIPE_FRAMES} layout="none">
<CurtainFront box={box} />
</Sequence>
))}
</>
);
};
/** The first picture as it looks on frame 0, wiped back in at the end of the video. */
const LoopPicture: FC<{ slide: Slide; box: Box }> = ({ slide, box }) => {
const frame = useCurrentFrame();
return (
<div
style={{
position: "absolute",
inset: 0,
clipPath: curtainPolygon("behind", wipeProgress(frame), { ...box, cell: CURTAIN_CELL }),
}}
>
<Freeze frame={0}>
<Picture slide={slide} box={box} wipeIn={false} wipeOutAt={null} />
</Freeze>
</div>
);
};
/** The headline of one slide, and what follows it. `settled` shows it complete from its first frame. */
const Words: FC<{ slide: Slide; settled: boolean; width: number }> = ({ slide, settled, width }) => {
const frame = useCurrentFrame();
const enter = settled ? 1 : interpolate(frame, [0, ENTER_FRAMES], [0, 1], clampOpts);
const exit = interpolate(
frame,
[slide.durationInFrames - EXIT_FRAMES, slide.durationInFrames - 1],
[1, 0],
clampOpts,
);
const lines = useCopy().slides[slide.id] ?? [];
const style: CSSProperties = {
fontFamily: FONT.sans,
fontWeight: 900,
fontSize: headlineSize(lines, width),
lineHeight: 0.98,
letterSpacing: "-0.04em",
wordSpacing: "0.08em",
};
return (
<div style={{ position: "absolute", inset: 0, opacity: Math.min(enter, exit) }}>
{lines.map((line, index) => (
<div key={line} style={{ ...style, color: index > 0 && index === lines.length - 1 ? COLOR.faint : COLOR.ink }}>
<BitWipe from={settled ? -1000 : 3 + index * 4} durationInFrames={16} digitSize={18}>
{line}
</BitWipe>
</div>
))}
{slide.after}
</div>
);
};
/** Fades its children in over the exit of the last slide, so the last frame equals the first. */
const LoopBack: FC<{ children: ReactNode }> = ({ children }) => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, EXIT_FRAMES - 1], [0, 1], clampOpts);
return <div style={{ position: "absolute", inset: 0, opacity }}>{children}</div>;
};
/**
* A looping sequence of slides: big picture, few words. The pictures share one dark panel and are
* changed by a curtain of digits; the headlines sit below the panel (portrait and square) or beside
* it (16:9). The first slide is complete on frame 0, which is the preview image, and returns at the end.
*/
export const SlideDeck: FC<{ slides: readonly Slide[] }> = ({ slides }) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const wide = width > height;
const stage = wide ? WIDE_STAGE : { width: STAGE_WIDTH, height: TALL_STAGE_HEIGHT };
// As large as the frame allows: the drawing is what matters.
const scale = Math.min(
(height - FRAME.top - FRAME.bottom) / stage.height,
(width - FRAME.side * 2) / stage.width,
);
const starts = slides.map((_, index) =>
slides.slice(0, index).reduce((sum, slide) => sum + slide.durationInFrames, 0),
);
const total = deckDuration(slides);
const first = slides[0];
// In portrait the panel makes room for a slide that has more below its headline.
const shrink = wide
? 0
: Math.max(
0,
...slides.map((slide, index) => {
if (!slide.after) return 0;
const start = starts[index] ?? 0;
const end = start + slide.durationInFrames;
return Math.min(
interpolate(frame, [start, start + ENTER_FRAMES], [0, 1], clampOpts),
interpolate(frame, [end - WIPE_FRAMES, end - 1], [1, 0], clampOpts),
);
}),
);
const panel = { width: STAGE_WIDTH, height: PANEL_HEIGHT - (PANEL_HEIGHT - PANEL_HEIGHT_WITH_AFTER) * easeOut(shrink) };
const textWidth = wide ? WIDE_TEXT_WIDTH : STAGE_WIDTH;
return (
<div style={{ position: "relative", ...stage, flexShrink: 0, transform: `scale(${scale})` }}>
<div style={{ position: "absolute", top: 0, left: 0 }}>
<Panel {...panel}>
<Pictures slides={slides} starts={starts} total={total} box={panel} />
</Panel>
</div>
<div
style={
wide
? { position: "absolute", top: 200, left: STAGE_WIDTH + WIDE_GAP, width: WIDE_TEXT_WIDTH, height: TEXT_HEIGHT }
: { position: "absolute", top: panel.height + GAP, left: 0, width: STAGE_WIDTH, height: TEXT_HEIGHT }
}
>
{slides.map((slide, index) => (
<Sequence key={slide.id} from={starts[index]} durationInFrames={slide.durationInFrames} layout="none">
<Words slide={slide} settled={index === 0} width={textWidth} />
</Sequence>
))}
{first && (
<Sequence from={total - EXIT_FRAMES} durationInFrames={EXIT_FRAMES} layout="none">
<LoopBack>
<Freeze frame={0}>
<Words slide={first} settled width={textWidth} />
</Freeze>
</LoopBack>
</Sequence>
)}
</div>
</div>
);
};
export const deckDuration = (slides: readonly Slide[]): number =>
slides.reduce((sum, slide) => sum + slide.durationInFrames, 0);
+19
View File
@@ -0,0 +1,19 @@
import type { FC } from "react";
import { BrandButton } from "../brand/BrandButton";
import { CTA_SCENE_HEIGHT } from "../illustrations/scenes";
import type { Slide } from "./SlideDeck";
const CTA_FRAMES = 75;
/** The closing slide of every video: a smaller picture, the last statement and the website's button. */
export const ctaSlide = (Illustration: FC): Slide => ({
id: "cta",
durationInFrames: CTA_FRAMES,
Illustration,
illustrationHeight: CTA_SCENE_HEIGHT,
after: (
<div style={{ marginTop: 48 }}>
<BrandButton label="Get in touch" size={28} />
</div>
),
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"noEmit": true,
"types": [
"node"
],
"allowImportingTsExtensions": true
},
"include": [
"src",
"scripts",
"remotion.config.ts"
]
}
+17 -10
View File
@@ -1,7 +1,9 @@
# mintel.me relaunch concept: freelance engineer site
Scope: this website only. Goal: a visitor understands what I sell within 10 seconds and
books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable hours.
Scope: this website, plus the videos made from it (`apps/video`, see section 6). Goal: a visitor understands
what I sell within 10 seconds and writes an email within 2 minutes. Low sales overhead, more bookable hours.
Status note: sections 3 to 6 describe the site as built. Sections 2 and 9 are kept as history and are marked as such.
## 1. Positioning
@@ -13,10 +15,10 @@ books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable
- **Never mention AI as my own tool.** How I work is none of the client's business: no "AI-built", "AI-accelerated", "AI-code rescue", no FAQ about AI tools. The one exception is the automation service, where AI steps are part of what the client gets (sorting, summarising, reading documents).
- **Modest and factual.** No promises I have not made (response times, weekly updates, start dates, search ranking). Only state what the owner confirmed. No superlatives.
- **No internal method vocabulary** on client-facing pages: no specs, TDD, mutation testing, coverage, pull requests. Translate into what the client gets: changes do not break things, a written update every week, predictable cost. Technical detail belongs on `/process` and `/notes`, for engineers.
- **No internal method vocabulary** on client-facing pages: no specs, TDD, mutation testing, coverage, pull requests. Translate into what the client gets: changes do not break things, predictable cost.
- Do not say: "everything", "fullstack for anyone", "vibe coding". No location or tax talk; say "remote, available during European business hours".
- Pick the visitor up: introduce me first, then let them choose their need (developer for a product, make an app reliable, just a website), then explain for that need what they get, how it runs, what it costs.
- Language (owner decision, 2026-10-04): German is the main language at the root, English lives under `/en`. The language is detected from the browser and can be switched in the header.
- Language (owner decision, 2026-10-04): German is the main language at the root, English lives under `/en`. The language is detected from the browser and can be switched in the header. The videos and their post texts exist in both languages too (`apps/video/src/content/videos.ts`).
### Audiences (priority order)
@@ -29,7 +31,10 @@ books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable
- Offer: websites and web apps (Next.js/TypeScript, strong design), light backend (APIs, auth, integrations), CLI tools and automation, AI agents/MCP servers, architecture and code review, the spec/test/mutation process for teams, Web3 frontends. macOS apps on request only.
- Do not offer (state it plainly): Windows apps, mobile apps, running client infrastructure, database-heavy work, email/DNS administration.
## 2. Differentiator: verified AI speed
## 2. Differentiator: verified AI speed (internal, not shown on the site)
This is how the work is done. Under the copy rules above none of it appears in client-facing copy, and the pages
named below (`/process`, the sample report) were not built: the site is one page (section 3).
Anyone can generate code. The scarce thing is code that survives production.
Spec first -> red -> green -> refactor -> mutation check on changed code -> coverage as gap finder -> a delivery report with exact commands and results on every PR.
@@ -57,9 +62,10 @@ The old site (`/blog`, `/case-studies`, `/contact`, `/websites`, `/technologies`
## 4. Design
- Light, the original look of the site: white, oversized type, binary texture, one accent (the yellow marker).
- The hero headline is built from running binary digits ("SHIP FAST. / BREAK NOTHING."). The owner approved it: do not change the hero design.
- The hero headline is built from running binary digits ("WEB APPS / & WEBSITES."). The owner approved the design: do not change it.
- One left axis for logo, navigation and content. No sidebars. Few sections, each with one job.
- Availability line fed by one constant, shown in the hero, the sticky booking bar and the closing section.
- No availability line and no booking bar: both were dropped with the calendar. The hero shows two facts instead (15 years in web; remote, EU core hours).
- Dark surfaces carry light: `BitAura` (light along an edge, digits drifting off it), `BitSeam` (lit top edge of a dark section) and `BitGlow` (light following the pointer on `data-glow` surfaces, including the dark button).
## 5. Offers and pricing
@@ -92,8 +98,9 @@ Audit of an AI-built codebase, test foundation, mutation baseline, prioritized r
## 6. Getting clients (low effort first)
1. Own network: former colleagues from i22, Trusted Shops, sdox.io, Sevenval. Ten personal messages.
2. LinkedIn and X: one post a week from real work (process, numbers, diagrams), one pinned "next free block" post.
3. Platforms (freelancermap, freelance.de, Web3 job boards) as a secondary channel.
2. LinkedIn and X: one post a week. A set of short looping videos, one per service, with post texts in English and German, is produced in `apps/video` (`node scripts/export.ts` writes `out/library`).
3. Google Ads for German clients: search campaigns for "website" and "automation". Concept, ad texts and open prerequisites in `plans/google-ads.md`.
4. Platforms (freelancermap, freelance.de, Web3 job boards) as a secondary channel.
4. Direct outreach: 10 targeted messages a week with one template.
5. Testimonials: one line each from existing clients.
@@ -110,7 +117,7 @@ Audit of an AI-built codebase, test foundation, mutation baseline, prioritized r
- Impressum needs a serviceable address (DDG section 5); settle it with a tax advisor. Do not publish tax, visa or payment-provider details.
- Scheinselbststaendigkeit: prefer 15-20 h blocks over one client at 35-40 h.
## 9. Implementation order
## 9. Implementation order (history: superseded by the one-page site in section 3)
1. Cal.com event and `/book`.
2. Spec tests for availability and redirects, then new hero, nav and offers; remove old SMB sections.
+158
View File
@@ -0,0 +1,158 @@
# Google Ads concept: German clients at low cost
Status: draft for the owner's review. Nothing has been created in the Ads account, and no API call has been made.
Wording and prices come from the website's German texts (`apps/web/src/content/de.ts`). Search volumes and click prices are **not** in
this document: they have to come from the Keyword Planner once API access works (section 7).
## 1. Goal and principle
Goal: enquiries by email from German companies, for the two services a layperson searches for:
a website, and process automation. Small budget, so every euro goes to people who are already looking.
- **Search ads first.** Someone who types "Website erstellen lassen" has a need today. Video and display ads
reach people who do not, and cost more per enquiry.
- **Narrow, then widen.** Few exact search terms, one country, business hours. Widen only what produces enquiries.
- **Measure enquiries, not clicks.** Without counting email clicks per search term, nothing can be steered.
- The videos (`apps/video`) are for LinkedIn posts and, later, for showing to people who already visited the site.
## 2. Before the first euro is spent
| # | Prerequisite | Why | State |
| - | :----------- | :-- | :---- |
| 1 | German landing page | German ad, German page. | Done: German is the main language of the site since 2026-10-04. Ads lead to the homepage, to the section of the service. Own pages per campaign would convert better, but the site is one page by decision |
| 2 | Count email clicks | `EmailLink` does not record a click today. Umami is on the site and needs no cookie banner. | Open: add an event, e.g. `contact_email_click`, with the campaign from the URL |
| 3 | Campaign tagging | Every ad URL carries `utm_source=google&utm_medium=cpc&utm_campaign=<name>&utm_term={keyword}` so Umami shows which search term led to an email click. | With the ads |
| 4 | Developer token and customer number | The Ads API rejects every call without them. `gcloud` login with the Ads scope and the enabled API exist (project `mintelme-402610`). | Open: owner provides both |
| 5 | Payment method and billing country in the Ads account | Cannot be checked from here. | Open: owner |
On prerequisite 2: Google's own conversion tag would let Google optimise bids by itself, but it sets cookies and
needs a consent banner. Recommendation: start without it, judge by Umami, and decide about the tag after the
first month.
## 3. Account structure
Two search campaigns at the start, one ad group each, so that budget and results stay readable.
| Setting | Value | Reason |
| :------ | :---- | :----- |
| Networks | Google Search only; search partners and Display off | Partners and Display bring cheap clicks that rarely enquire |
| Location | Germany, "presence" only (people who are there, not people interested in it) | Default setting includes people abroad |
| Language | German | |
| Schedule | Monday to Friday, 7:00 to 19:00 | Business owners search during working hours; review after two weeks |
| Match types | Phrase and exact only, no broad match | Broad match spends a small budget on unrelated searches |
| Bidding | Maximise clicks with a click-price cap | There is no conversion data yet for automatic bidding |
| Budget | A fixed daily amount per campaign, set by the owner | See section 6 |
## 4. Campaign "Website"
Landing page: the German homepage, services section, case "website".
Search terms (phrase and exact):
- website erstellen lassen
- homepage erstellen lassen
- firmenwebsite erstellen lassen
- webseite erstellen lassen kosten
- website für unternehmen erstellen lassen
- webdesigner freelancer
- webentwickler freelancer
Excluded terms: kostenlos, gratis, selbst, selber, baukasten, wix, jimdo, squarespace, wordpress, shopify,
onlineshop, vorlage, template, kurs, lernen, ausbildung, studium, job, jobs, stellenangebot, gehalt, praktikum.
Responsive search ad. Headlines (at most 30 characters each):
- H: Website erstellen lassen
- H: Firmenwebsite vom Entwickler
- H: Ab 3.800 € netto
- H: 15 Jahre Web-Erfahrung
- H: Design, Bau und Hosting
- H: Änderungen per E-Mail
- H: Kein Baukasten nötig
- H: Erstgespräch: 20 Minuten
- H: Freelancer statt Agentur
- H: Passt auf Handy und Desktop
- H: Marc Mintel, Webentwickler
- H: Individuelles Design
Descriptions (at most 90 characters each):
- D: Sie sagen, was Ihr Unternehmen macht. Ich gestalte, baue und betreue Ihre Website.
- D: Ein Design für Ihr Unternehmen statt einer Vorlage. Auf Handy, Tablet und Desktop.
- D: Um das Hosting kümmere ich mich. Änderungen schicken Sie mir einfach per E-Mail.
- D: Schätzung nach dem ersten Gespräch. Eine typische Firmenwebsite ab 3.800 € netto.
Sitelinks: "So läuft es ab", "Was es kostet", "Meine Arbeiten", "Kontakt".
## 5. Campaign "Automatisierung"
Landing page: the German homepage, services section, case "automation".
Search terms (phrase and exact):
- prozesse automatisieren
- geschäftsprozesse automatisieren
- prozessautomatisierung unternehmen
- schnittstelle programmieren lassen
- api anbindung programmieren lassen
- rechnungen automatisch erfassen
- systeme verbinden schnittstelle
Excluded terms: definition, beispiele, bachelorarbeit, masterarbeit, studium, kurs, schulung, zertifizierung,
job, jobs, stellenangebot, gehalt, kostenlos, sap, uipath, power automate, zapier, make.
The last four are tool names. People searching for them want the tool, not a developer. Review after two weeks:
"zapier" or "make" plus "einrichten lassen" could be worth an own ad group.
Responsive search ad. Headlines:
- H: Prozesse automatisieren
- H: Systeme verbinden lassen
- H: Schluss mit Copy-and-paste
- H: Schnittstellen vom Entwickler
- H: Rechnungen automatisch lesen
- H: Von Bestellung bis Rechnung
- H: 100 € pro Stunde, netto
- H: Erst Schätzung, dann Budget
- H: Erstgespräch: 20 Minuten
- H: 15 Jahre Web-Erfahrung
- H: Ihre Tools, verbunden
- H: Keine Anfrage geht verloren
Descriptions:
- D: Ich verbinde die Tools, die Sie schon nutzen. Wiederkehrende Schritte laufen von selbst.
- D: Daten von Hand von einem Tool ins andere tippen? Das lässt sich meist automatisieren.
- D: Ich sehe mir den Ablauf an und schätze den Aufwand. Die Schätzung wird Ihr Budget.
- D: Menschen entscheiden die Ausnahmen, die Routine läuft von selbst. Erstgespräch: 20 Min.
Sitelinks: "Beispiele", "So läuft es ab", "Was es kostet", "Kontakt".
## 6. Budget and when to stop
The amounts are the owner's decision. A workable way to set them:
- Decide what one new client is worth, and how much of that may go into finding them.
- Run both campaigns for four weeks at a fixed daily amount. Do not change anything in the first week.
- Weekly, 15 minutes: read the search terms report, add wasted terms to the excluded list, pause search terms
that got clicks but no email click.
- After four weeks: a campaign that produced no enquiry is stopped or rebuilt, not given more budget.
- Automatic bidding on enquiries only once there are enough of them to learn from (Google's guidance is in the
order of 30 conversions a month; check the current figure before switching).
## 7. Later steps
1. **Numbers first.** With API access: pull monthly searches and click-price ranges for the terms above
(Keyword Planner), drop terms nobody searches, and set the click-price cap from real figures.
2. **Developer campaign.** Terms like "react entwickler freelancer", "next.js entwickler freelance",
"frontend entwickler freelancer". Fewer searches, and staffing platforms bid on them. Start only when the
first two campaigns are understood. Video: `ProductDeveloper-de`.
3. **"Make an app reliable".** Wait until the offer and its price are settled.
4. **Videos as ads.** Upload the 16:9 versions (`*-de-wide.mp4`) to YouTube and show them only to people who
visited the site in the last 30 days. This needs Google's tag and therefore the consent banner (section 2).
## 8. What Claude may do with the API
- Read: list the account, campaigns, search terms and Keyword Planner figures.
- Prepare: create campaigns, ad groups, ads and excluded terms **paused**, after the owner approved this document.
- Never: enable a campaign, set or raise a budget, or change billing. That stays with the owner.