Merge feat/video-playground: content strategy, image posts and posting plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
description: Make or change a social media film or image post 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
|
||||
@@ -87,9 +87,24 @@ Frame 0 is the preview image: it must already look good and carry the hook line.
|
||||
- **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.
|
||||
|
||||
## Image posts
|
||||
|
||||
Every film gets a poster, and the plan pairs it with a card (see `apps/web/plans/content-strategy.md`).
|
||||
|
||||
- Poster: add an entry to `src/content/images.ts` with `poster: { video, frame }`. Pick the frame where the
|
||||
whole picture is in view and the problem is obvious (around frame 250 to 300). Two lines, as short as
|
||||
possible: "Kein Pingpong." / "Ihr Büro."
|
||||
- Card: a question a client asks before the first call (`label`) and the answer in two or three lines.
|
||||
The answer must be on the website already.
|
||||
- Add the new posts to `src/content/plan.ts`, run `pnpm test`, then `node scripts/export.ts --images`
|
||||
and look at the PNGs in `feed` and `square`.
|
||||
- A poster is not a frozen frame inside a one-frame image: `Freeze` did not work there. The poster is a
|
||||
composition as long as the film, rendered at that frame.
|
||||
|
||||
## 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.
|
||||
`out/library/<lang>/week-<n>/<slot>-<day>-<id>/`, with `post.txt` and an overview page
|
||||
`out/library/index.html`. `out/` is not committed. Keep it tidy: check frames, contact sheets and logs
|
||||
go into `out/tmp`, nothing loose into `out/`, and `out/tmp` is emptied before you report.
|
||||
|
||||
+15
-2
@@ -12,11 +12,12 @@ or `pnpm -r`. `apps/web` must never import from here, and this package imports n
|
||||
- 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`.
|
||||
text, download) and `out/library/<language>/week-<n>/<slot>-<day>-<id>/` with the files and `post.txt`, in plan order.
|
||||
- `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.
|
||||
`wide` (16:9), `square`, `story` (9:16). `out/` is gitignored and holds only two things: `out/library`
|
||||
(what gets posted) and `out/tmp` (your check frames, sheets and logs; delete it when you are done).
|
||||
|
||||
## Where things are
|
||||
|
||||
@@ -56,6 +57,18 @@ Every video is a film in `src/films`: one continuous shot on a dark stage, not a
|
||||
- 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.
|
||||
|
||||
## Image posts and the posting plan
|
||||
|
||||
- Image posts are made the same way as films: `src/content/images.ts` holds every word and, for a poster, which
|
||||
film and frame it shows; `src/images/ImagePost.tsx` draws them. A poster is a film rendered at one frame with
|
||||
two lines ("Not ping-pong. Your office."); a card is a client's question with the answer in large type.
|
||||
Cards say only what the website says. Compositions: `<ImageId>-<language>-<feed|square>`.
|
||||
- `src/content/plan.ts` is the posting plan (which film or image in which week); the reasoning is in
|
||||
`apps/web/plans/content-strategy.md`. The spec checks that every film and image is planned exactly once
|
||||
and that every video post opens with the hook of its film.
|
||||
- `pnpm library` renders films and images and writes the overview page in plan order;
|
||||
`--images`, `--videos`, `--only <id>`, `--page`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Before any work: bring the worktree up to date with `main` (owner feedback, twice). Take wording, styles and
|
||||
|
||||
+129
-61
@@ -1,21 +1,30 @@
|
||||
/**
|
||||
* 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.
|
||||
* Renders the content library: every film and every image post in every language, with the text
|
||||
* to post next to it, and an overview page in the order of the posting plan.
|
||||
*
|
||||
* 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 --images only the image posts
|
||||
* node scripts/export.ts --videos only the films
|
||||
* node scripts/export.ts --only WebsiteDone one film or one image
|
||||
* node scripts/export.ts --formats feed one video 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>/.
|
||||
* Output, in the order of the posting plan:
|
||||
* out/library/index.html
|
||||
* out/library/<language>/week-<n>/<slot>-<day>-<id>/ the files to upload and post.txt
|
||||
* Anything else in out/ is scratch: put checks and logs into out/tmp, which can be deleted at any time.
|
||||
* Run one export at a time, and redirect its log with `>|`, not `>`.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { IMAGES, IMAGE_IDS, type ImageId } from "../src/content/images.ts";
|
||||
import { PLAN, POSTING_DAYS, type PlannedPost } from "../src/content/plan.ts";
|
||||
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 DEFAULT_VIDEO_FORMATS = ["feed", "wide"];
|
||||
const IMAGE_FORMATS = ["feed", "square"];
|
||||
const FORMAT_NOTE: Readonly<Record<string, string>> = {
|
||||
feed: "4:5, LinkedIn feed",
|
||||
wide: "16:9, YouTube and Google Ads",
|
||||
@@ -27,32 +36,66 @@ const option = (name: string): string | undefined => {
|
||||
const index = process.argv.indexOf(`--${name}`);
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
};
|
||||
const flag = (name: string): boolean => process.argv.includes(`--${name}`);
|
||||
|
||||
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 videoFormats = option("formats")?.split(",") ?? DEFAULT_VIDEO_FORMATS;
|
||||
const pageOnly = flag("page");
|
||||
const wanted = (id: string): boolean => only === undefined || id === only;
|
||||
const videoIds = flag("images") ? [] : VIDEO_IDS.filter(wanted);
|
||||
const imageIds = flag("videos") ? [] : IMAGE_IDS.filter(wanted);
|
||||
if (videoIds.length + imageIds.length === 0) {
|
||||
throw new Error(`Nothing to export for "${only}". Known: ${[...VIDEO_IDS, ...IMAGE_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);
|
||||
/** Where a post lives inside the library: by language, then week, then day, as it will be posted. */
|
||||
const place = (id: string, lang: Lang): string => {
|
||||
for (const [weekIndex, week] of PLAN.entries()) {
|
||||
const slot = week.posts.findIndex((post) => post.id === id);
|
||||
if (slot !== -1) {
|
||||
return `${lang}/week-${weekIndex + 1}/${slot + 1}-${(POSTING_DAYS[slot] ?? "").toLowerCase()}-${id}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`${id} is not in the posting plan (src/content/plan.ts)`);
|
||||
};
|
||||
const folder = (id: string, lang: Lang): string => join(LIBRARY, place(id, lang));
|
||||
const fileName = (id: string, lang: Lang, format: string, extension: string): string =>
|
||||
`${id}-${lang}-${format}.${extension}`;
|
||||
|
||||
for (const id of videoIds) {
|
||||
for (const lang of LANGS) {
|
||||
/** Runs a Remotion command and makes sure it really wrote the file. */
|
||||
const render = (command: "render" | "still", composition: string, target: string, frame = 0): void => {
|
||||
console.log(`Rendering ${target}`);
|
||||
const startedAt = Date.now();
|
||||
const extra = command === "still" ? [`--frame=${frame}`] : [];
|
||||
execFileSync("pnpm", ["exec", "remotion", command, "src/index.ts", composition, target, "--log=error", ...extra], {
|
||||
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 postText = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
|
||||
kind === "video" ? COPY[id as VideoId][lang].post : IMAGES[id as ImageId].copy[lang].post;
|
||||
const title = (kind: PlannedPost["kind"], id: string, lang: Lang): string =>
|
||||
kind === "video" ? COPY[id as VideoId][lang].title : IMAGES[id as ImageId].copy[lang].title;
|
||||
|
||||
for (const lang of LANGS) {
|
||||
for (const id of videoIds) {
|
||||
mkdirSync(folder(id, lang), { recursive: true });
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${COPY[id][lang].post}\n`);
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("video", id, lang)}\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.`);
|
||||
}
|
||||
for (const format of videoFormats) {
|
||||
render("render", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "mp4")));
|
||||
}
|
||||
}
|
||||
for (const id of imageIds) {
|
||||
mkdirSync(folder(id, lang), { recursive: true });
|
||||
writeFileSync(join(folder(id, lang), "post.txt"), `${postText("image", id, lang)}\n`);
|
||||
if (pageOnly) continue;
|
||||
for (const format of IMAGE_FORMATS) {
|
||||
render("still", `${id}-${lang}-${format}`, join(folder(id, lang), fileName(id, lang, format, "png")), IMAGES[id].poster?.frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,70 +103,94 @@ for (const id of videoIds) {
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
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))))
|
||||
const media = (post: PlannedPost, lang: Lang): string => {
|
||||
const extension = post.kind === "video" ? "mp4" : "png";
|
||||
const shown = Object.keys(FORMAT_NOTE)
|
||||
.filter((format) => existsSync(join(folder(post.id, lang), fileName(post.id, lang, format, extension))))
|
||||
.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>`;
|
||||
const src = `${place(post.id, lang)}/${fileName(post.id, lang, format, extension)}`;
|
||||
const element =
|
||||
post.kind === "video"
|
||||
? `<video src="${src}" controls loop muted playsinline preload="metadata"></video>`
|
||||
: `<img src="${src}" alt="" loading="lazy">`;
|
||||
return `<figure class="${format}">${element}<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>
|
||||
return shown || "<p>Not rendered yet.</p>";
|
||||
};
|
||||
|
||||
const card = (post: PlannedPost, day: string, lang: Lang): string => `<article lang="${lang}" data-lang="${lang}">
|
||||
<header><span class="tag">${day} · ${post.kind}</span><h3>${escapeHtml(title(post.kind, post.id, lang))}</h3></header>
|
||||
<div class="media">${media(post, lang)}</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 class="post-head"><span class="tag">Post text</span><button type="button" class="copy">Copy</button></div>
|
||||
<textarea readonly rows="12">${escapeHtml(postText(post.kind, post.id, lang))}</textarea>
|
||||
</div>
|
||||
</article>`;
|
||||
};
|
||||
|
||||
const weeks = PLAN.map(
|
||||
(week, index) => `<section>
|
||||
<h2><span class="tag">Week ${index + 1}</span>${escapeHtml(week.topic)}</h2>
|
||||
${week.posts.flatMap((post, slot) => LANGS.map((lang) => card(post, POSTING_DAYS[slot] ?? "", lang))).join("\n")}
|
||||
</section>`,
|
||||
).join("\n");
|
||||
|
||||
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>
|
||||
<title>Content 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; }
|
||||
.intro { color: var(--muted); font-size: 18px; max-width: 62ch; margin: 0 0 24px; }
|
||||
.switch { display: flex; gap: 8px; margin-bottom: 40px; }
|
||||
section { border-top: 2px solid var(--ink); padding-top: 20px; margin-top: 56px; }
|
||||
h2 { font-size: 34px; letter-spacing: -0.03em; margin: 0 0 8px; display: flex; flex-direction: column; gap: 8px; }
|
||||
h3 { font-size: 22px; letter-spacing: -0.02em; margin: 0; }
|
||||
article { border-top: 1px solid var(--line); padding: 28px 0; display: grid; gap: 20px; }
|
||||
@media (min-width: 900px) { article { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); } article header { grid-column: 1 / -1; } }
|
||||
body[data-show="de"] article[data-lang="en"], body[data-show="en"] article[data-lang="de"] { display: none; }
|
||||
header { display: flex; flex-direction: column; gap: 6px; }
|
||||
.tag, figcaption { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
|
||||
.media { display: flex; flex-wrap: wrap; gap: 20px; 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); }
|
||||
video, img { display: block; width: 100%; border-radius: 16px; background: var(--ink); }
|
||||
figure.feed, figure.square { width: min(100%, 300px); }
|
||||
figure.story { width: min(100%, 230px); }
|
||||
figure.wide { width: min(100%, 460px); }
|
||||
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; }
|
||||
button { border: 1px solid var(--ink); 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; }
|
||||
button[aria-pressed="false"] { background: #fff; color: var(--ink); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body data-show="de">
|
||||
<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")}
|
||||
<h1>Content library</h1>
|
||||
<p class="intro">Six weeks, three posts each: the film on Tuesday, a card on Thursday, the poster on Saturday. The plan is in <code>src/content/plan.ts</code>, the reasoning in <code>apps/web/plans/content-strategy.md</code>.</p>
|
||||
<div class="switch">
|
||||
<button type="button" data-set="de" aria-pressed="true">Deutsch</button>
|
||||
<button type="button" data-set="en" aria-pressed="false">English</button>
|
||||
</div>
|
||||
${weeks}
|
||||
</main>
|
||||
<script>
|
||||
for (const button of document.querySelectorAll(".post button")) {
|
||||
for (const button of document.querySelectorAll("[data-set]")) {
|
||||
button.addEventListener("click", () => {
|
||||
document.body.dataset.show = button.dataset.set;
|
||||
for (const other of document.querySelectorAll("[data-set]")) other.setAttribute("aria-pressed", String(other === button));
|
||||
});
|
||||
}
|
||||
for (const button of document.querySelectorAll(".copy")) {
|
||||
button.addEventListener("click", async () => {
|
||||
const text = button.closest(".post").querySelector("textarea").value;
|
||||
await navigator.clipboard.writeText(text);
|
||||
await navigator.clipboard.writeText(button.closest(".post").querySelector("textarea").value);
|
||||
button.textContent = "Copied";
|
||||
setTimeout(() => { button.textContent = "Copy"; }, 1500);
|
||||
});
|
||||
@@ -133,5 +200,6 @@ const page = `<!doctype html>
|
||||
</html>
|
||||
`;
|
||||
|
||||
mkdirSync(LIBRARY, { recursive: true });
|
||||
writeFileSync(join(LIBRARY, "index.html"), page);
|
||||
console.log(`Overview: ${join(LIBRARY, "index.html")}`);
|
||||
|
||||
+24
-17
@@ -1,27 +1,18 @@
|
||||
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 { IMAGES, IMAGE_IDS } from "./content/images";
|
||||
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,
|
||||
];
|
||||
import { ImagePost } from "./images/ImagePost";
|
||||
import { VIDEOS } from "./registry";
|
||||
import { SHOT_FRAMES } from "./films/shot";
|
||||
import { ServiceVideo, videoDuration } from "./slides/ServiceVideo";
|
||||
|
||||
const FORMAT_NAMES = Object.keys(FORMATS) as FormatName[];
|
||||
/** Images are posted in the feed: 4:5, and square for places that crop. */
|
||||
const IMAGE_FORMATS = ["feed", "square"] as const;
|
||||
|
||||
/** Videos are `<VideoId>-<language>-<format>`, images `<ImageId>-<language>-<feed|square>`. */
|
||||
export const Root: FC = () => (
|
||||
<>
|
||||
{VIDEOS.flatMap((video) =>
|
||||
@@ -39,5 +30,21 @@ export const Root: FC = () => (
|
||||
)),
|
||||
),
|
||||
)}
|
||||
{IMAGE_IDS.flatMap((id) =>
|
||||
LANGS.flatMap((lang) =>
|
||||
IMAGE_FORMATS.map((format) => (
|
||||
// A poster is a film at one of its frames, so it needs the film's length; a card is a single frame.
|
||||
<Composition
|
||||
key={`${id}-${lang}-${format}`}
|
||||
id={`${id}-${lang}-${format}`}
|
||||
component={() => <ImagePost id={id} lang={lang} />}
|
||||
durationInFrames={IMAGES[id].poster ? SHOT_FRAMES : 1}
|
||||
fps={FPS}
|
||||
width={FORMATS[format].width}
|
||||
height={FORMATS[format].height}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AppFilm } from "../films/AppFilm";
|
||||
import { AppDrawing, 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 };
|
||||
export const appRescue: VideoEntry = {
|
||||
id: "AppRescue",
|
||||
Film: AppFilm,
|
||||
Drawing: AppDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { DocumentsFilm } from "../films/DocumentsFilm";
|
||||
import { DocumentsDrawing, 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 };
|
||||
export const automationDocuments: VideoEntry = {
|
||||
id: "AutomationDocuments",
|
||||
Film: DocumentsFilm,
|
||||
Drawing: DocumentsDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { EnquiryFilm } from "../films/EnquiryFilm";
|
||||
import { EnquiryDrawing, 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 };
|
||||
export const automationEnquiry: VideoEntry = {
|
||||
id: "AutomationEnquiry",
|
||||
Film: EnquiryFilm,
|
||||
Drawing: EnquiryDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ORDER_FILM_FRAMES, OrderFilm } from "../films/OrderFilm";
|
||||
import { ORDER_FILM_FRAMES, OrderDrawing, 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,
|
||||
Drawing: OrderDrawing,
|
||||
durationInFrames: ORDER_FILM_FRAMES,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { DeveloperFilm } from "../films/DeveloperFilm";
|
||||
import { DeveloperDrawing, 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 };
|
||||
export const productDeveloper: VideoEntry = {
|
||||
id: "ProductDeveloper",
|
||||
Film: DeveloperFilm,
|
||||
Drawing: DeveloperDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { WebsiteFilm } from "../films/WebsiteFilm";
|
||||
import { WebsiteDrawing, 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 };
|
||||
export const websiteDone: VideoEntry = {
|
||||
id: "WebsiteDone",
|
||||
Film: WebsiteFilm,
|
||||
Drawing: WebsiteDrawing,
|
||||
durationInFrames: SHOT_FRAMES,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Image posts: every word, in English and German, and for posters which film and which frame they
|
||||
* show. Two kinds:
|
||||
*
|
||||
* - a poster is one picture of a film (the composition is rendered at that frame) with the film's idea in two lines
|
||||
* - a card answers one question a client asks before the first call, in large type
|
||||
*
|
||||
* Everything said here is on the website (`apps/web/src/content/de.ts`, questions and principles).
|
||||
* This file imports only types, so the export script can read it with plain Node.
|
||||
*/
|
||||
import type { Lang, VideoId } from "./videos.ts";
|
||||
|
||||
export type ImageCopy = {
|
||||
/** What the image is about, for the overview page. */
|
||||
readonly title: string;
|
||||
/** Cards only: the question, set small above the answer. */
|
||||
readonly label?: string;
|
||||
/** The large lines: at most three, as short as possible. */
|
||||
readonly lines: readonly string[];
|
||||
/** Text to post with the image. */
|
||||
readonly post: string;
|
||||
};
|
||||
|
||||
export type ImagePost = {
|
||||
/** Posters only: the film whose picture is shown, and at which frame. */
|
||||
readonly poster?: { readonly video: VideoId; readonly frame: number };
|
||||
readonly copy: Readonly<Record<Lang, ImageCopy>>;
|
||||
};
|
||||
|
||||
export const IMAGE_IDS = [
|
||||
"PosterOrder",
|
||||
"PosterDocuments",
|
||||
"PosterEnquiry",
|
||||
"PosterWebsite",
|
||||
"PosterApp",
|
||||
"PosterDeveloper",
|
||||
"CardNotTechnical",
|
||||
"CardOneContact",
|
||||
"CardOwnership",
|
||||
"CardHours",
|
||||
"CardNotice",
|
||||
"CardNotMyField",
|
||||
] as const;
|
||||
export type ImageId = (typeof IMAGE_IDS)[number];
|
||||
|
||||
const CALL_EN = "A first call takes about 20 minutes.\n\nwww.mintel.me";
|
||||
const CALL_DE = "Ein erstes Gespräch dauert etwa 20 Minuten.\n\nwww.mintel.me";
|
||||
|
||||
export const IMAGES: Readonly<Record<ImageId, ImagePost>> = {
|
||||
PosterOrder: {
|
||||
poster: { video: "AutomationOrder", frame: 285 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: ping-pong",
|
||||
lines: ["Not ping-pong.", "Your office."],
|
||||
post: `Every dot is someone carrying an order from one tool to the next. By hand.\n\nThe tools could pass it on themselves. I connect them.\n\n${CALL_EN}\n\n#automation #processautomation`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Pingpong",
|
||||
lines: ["Kein Pingpong.", "Ihr Büro."],
|
||||
post: `Jeder Punkt ist jemand, der eine Bestellung von einem Tool ins nächste trägt. Von Hand.\n\nDie Tools könnten das unter sich ausmachen. Ich verbinde sie.\n\n${CALL_DE}\n\n#automatisierung #prozessautomatisierung`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterDocuments: {
|
||||
poster: { video: "AutomationDocuments", frame: 300 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: skyscraper",
|
||||
lines: ["Not a skyscraper.", "Your inbox."],
|
||||
post: `Every floor is an invoice waiting to be typed in.\n\nIt can be read automatically. Clear cases are booked directly, unclear ones go to a person.\n\n${CALL_EN}\n\n#automation #accounting`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Hochhaus",
|
||||
lines: ["Kein Hochhaus.", "Ihr Posteingang."],
|
||||
post: `Jedes Stockwerk ist eine Rechnung, die noch jemand abtippen muss.\n\nDas lässt sich automatisch auslesen. Klare Fälle werden direkt gebucht, unklare gehen an einen Menschen.\n\n${CALL_DE}\n\n#automatisierung #buchhaltung`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterEnquiry: {
|
||||
poster: { video: "AutomationEnquiry", frame: 250 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: stars",
|
||||
lines: ["Not a night sky.", "Your enquiries."],
|
||||
post: `The faint ones are the enquiries nobody followed up on.\n\nThe steps can be connected: the contact is created, the right person is told, and a reminder appears when nobody has replied.\n\n${CALL_EN}\n\n#automation #sales`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Sterne",
|
||||
lines: ["Kein Sternenhimmel.", "Ihre Anfragen."],
|
||||
post: `Die blassen sind die Anfragen, bei denen niemand nachgefasst hat.\n\nDie Schritte lassen sich verbinden: Der Kontakt wird angelegt, die zuständige Person erfährt davon, und wenn niemand geantwortet hat, erscheint eine Erinnerung.\n\n${CALL_DE}\n\n#automatisierung #vertrieb`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterWebsite: {
|
||||
poster: { video: "WebsiteDone", frame: 260 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: empty page",
|
||||
lines: ["Not a beginning.", "Your website."],
|
||||
post: `The website that has been on the list since last year.\n\nYou tell me what your business does. I design it, build it and put it online. Changes afterwards: you send me an email.\n\n${CALL_EN}\n\n#website #webdesign`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: leere Seite",
|
||||
lines: ["Kein Anfang.", "Ihre Website."],
|
||||
post: `Die Website, die seit letztem Jahr auf der Liste steht.\n\nSie erzählen mir, was Ihr Unternehmen macht. Ich gestalte sie, baue sie und bringe sie online. Änderungen danach: Sie schreiben mir eine E-Mail.\n\n${CALL_DE}\n\n#website #webdesign`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterApp: {
|
||||
poster: { video: "AppRescue", frame: 285 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: Jenga",
|
||||
lines: ["Not Jenga.", "Your app."],
|
||||
post: `It works. But bugs come back, and small changes take longer than they should.\n\nI take a week to go through the code, fix what is most urgent and write down what I would do next. In plain language.\n\n${CALL_EN}\n\n#webdevelopment #softwarequality`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Jenga",
|
||||
lines: ["Kein Jenga.", "Ihre App."],
|
||||
post: `Sie läuft. Aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten.\n\nIch nehme mir eine Woche, gehe den Code durch, behebe das Dringendste und schreibe auf, was ich als Nächstes tun würde. In klarer Sprache.\n\n${CALL_DE}\n\n#webentwicklung #softwarequalität`,
|
||||
},
|
||||
},
|
||||
},
|
||||
PosterDeveloper: {
|
||||
poster: { video: "ProductDeveloper", frame: 300 },
|
||||
copy: {
|
||||
en: {
|
||||
title: "Poster: loading bars",
|
||||
lines: ["Not loading bars.", "Your backlog."],
|
||||
post: `More work than your team can handle.\n\nI join on agreed days of the week and work on what is next on your list. No package to buy, no minimum term.\n\n${CALL_EN}\n\n#webdevelopment #freelance`,
|
||||
},
|
||||
de: {
|
||||
title: "Poster: Ladebalken",
|
||||
lines: ["Keine Ladebalken.", "Ihr Backlog."],
|
||||
post: `Mehr Arbeit, als Ihr Team schafft.\n\nIch komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes auf Ihrer Liste steht. Kein Paket, keine Mindestlaufzeit.\n\n${CALL_DE}\n\n#webentwicklung #freelancer`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotTechnical: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: not technical",
|
||||
label: "Not a technical person?",
|
||||
lines: ["Doesn't matter.", "You never have", "to read code."],
|
||||
post: `I explain what I am doing in plain language, and you can ask at any time.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: kein Technikmensch",
|
||||
label: "Kein Technikmensch?",
|
||||
lines: ["Macht nichts.", "Code müssen Sie", "nicht lesen."],
|
||||
post: `Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardOneContact: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: one contact",
|
||||
label: "Who does the work?",
|
||||
lines: ["The person", "you talk to."],
|
||||
post: `Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: ein Ansprechpartner",
|
||||
label: "Wer macht die Arbeit?",
|
||||
lines: ["Der, mit dem", "Sie sprechen."],
|
||||
post: `Oft geht eine Anfrage durch mehrere Hände, bevor sie beim Entwickler ankommt. Bei mir gibt es eine direkte Leitung zwischen Ihnen und dem, der die Arbeit macht.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardOwnership: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: ownership",
|
||||
label: "Who owns the code?",
|
||||
lines: ["You do.", "The design too."],
|
||||
post: `Code and design belong to you, and you can hand both to another developer.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Eigentum",
|
||||
label: "Wem gehört der Code?",
|
||||
lines: ["Ihnen.", "Das Design auch."],
|
||||
post: `Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardHours: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: hours",
|
||||
label: "What do you pay for?",
|
||||
lines: ["Hours you", "can check."],
|
||||
post: `I track my time. The monthly invoice shows what the hours were spent on.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Stunden",
|
||||
label: "Wofür zahlen Sie?",
|
||||
lines: ["Für Stunden, die Sie", "nachprüfen können."],
|
||||
post: `Ich erfasse meine Zeit. Die Monatsrechnung zeigt, wofür die Stunden angefallen sind.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotice: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: notice",
|
||||
label: "How long are you committed?",
|
||||
lines: ["Two weeks", "notice."],
|
||||
post: `Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.\n\n${CALL_EN}`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: Kündigungsfrist",
|
||||
label: "Wie lange sind Sie gebunden?",
|
||||
lines: ["Zwei Wochen", "Kündigungsfrist."],
|
||||
post: `Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.\n\n${CALL_DE}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
CardNotMyField: {
|
||||
copy: {
|
||||
en: {
|
||||
title: "Card: not my field",
|
||||
label: "What I do not do",
|
||||
lines: ["Mobile apps.", "Windows software.", "Running servers."],
|
||||
post: `If your project needs those, I say so in the first call.\n\nWhat I do: web apps, interfaces, websites, and connecting systems.\n\nwww.mintel.me`,
|
||||
},
|
||||
de: {
|
||||
title: "Karte: nicht mein Feld",
|
||||
label: "Was ich nicht mache",
|
||||
lines: ["Mobile Apps.", "Windows-Software.", "Serverbetrieb."],
|
||||
post: `Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.\n\nWas ich mache: Web-Apps, Oberflächen, Websites und das Verbinden von Systemen.\n\nwww.mintel.me`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IMAGES, IMAGE_IDS } from "./images";
|
||||
import { PLAN } from "./plan";
|
||||
import { COPY, LANGS, VIDEO_IDS } from "./videos";
|
||||
|
||||
/** Longer lines get too small to read on a phone. */
|
||||
const MAX_IMAGE_LINE = 28;
|
||||
const MAX_IMAGE_LINES = 3;
|
||||
|
||||
describe("image posts", () => {
|
||||
it.each(IMAGE_IDS)("%s has at most three short lines in every language", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { lines } = IMAGES[id].copy[lang];
|
||||
expect(lines.length, `${id} ${lang}`).toBeGreaterThan(0);
|
||||
expect(lines.length, `${id} ${lang}`).toBeLessThanOrEqual(MAX_IMAGE_LINES);
|
||||
for (const line of lines) expect(line.length, `${id} ${lang}: "${line}"`).toBeLessThanOrEqual(MAX_IMAGE_LINE);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(IMAGE_IDS)("%s has a post that names the website and never an email address", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post } = IMAGES[id].copy[lang];
|
||||
expect(post).toContain("www.mintel.me");
|
||||
expect(post).not.toContain("@");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(IMAGE_IDS)("%s is a poster of an existing film, or a card with a question", (id) => {
|
||||
const image = IMAGES[id];
|
||||
if (image.poster) {
|
||||
expect(VIDEO_IDS).toContain(image.poster.video);
|
||||
expect(image.poster.frame).toBeGreaterThanOrEqual(0);
|
||||
} else {
|
||||
for (const lang of LANGS) expect(image.copy[lang].label, `${id} ${lang}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("video posts", () => {
|
||||
it.each(VIDEO_IDS)("%s opens its post with the hook of the film", (id) => {
|
||||
for (const lang of LANGS) {
|
||||
const { post, slides } = COPY[id][lang];
|
||||
const lastHookWord = (slides.hook ?? []).join(" ").split(" ").pop()?.replace(/[.?!]/g, "") ?? "";
|
||||
expect(post.split("\n")[0], `${id} ${lang}`).toContain(lastHookWord);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("posting plan", () => {
|
||||
const planned = PLAN.flatMap((week) => week.posts);
|
||||
|
||||
it("uses every film and every image exactly once", () => {
|
||||
const videos = planned.filter((post) => post.kind === "video").map((post) => post.id);
|
||||
const images = planned.filter((post) => post.kind === "image").map((post) => post.id);
|
||||
expect([...videos].sort()).toEqual([...VIDEO_IDS].sort());
|
||||
expect([...images].sort()).toEqual([...IMAGE_IDS].sort());
|
||||
});
|
||||
|
||||
it("opens every week with its film", () => {
|
||||
for (const week of PLAN) expect(week.posts[0].kind, week.topic).toBe("video");
|
||||
});
|
||||
|
||||
it("pairs the film of a week with the poster of the same film", () => {
|
||||
for (const week of PLAN) {
|
||||
const film = week.posts[0].id;
|
||||
const posters = week.posts.filter((post) => post.kind === "image" && IMAGES[post.id].poster?.video === film);
|
||||
expect(posters, week.topic).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The posting plan: which film or image goes out in which week, in which order.
|
||||
* The reasoning is in `apps/web/plans/content-strategy.md`. Each week has one topic and three
|
||||
* posts: the film, the poster of the same idea, and a card that answers a question clients ask.
|
||||
*
|
||||
* This file imports only types, so the export script can read it with plain Node.
|
||||
*/
|
||||
import type { ImageId } from "./images.ts";
|
||||
import type { VideoId } from "./videos.ts";
|
||||
|
||||
export type PlannedPost =
|
||||
| { readonly kind: "video"; readonly id: VideoId }
|
||||
| { readonly kind: "image"; readonly id: ImageId };
|
||||
|
||||
export type PlannedWeek = {
|
||||
readonly topic: string;
|
||||
/** In posting order: Tuesday, Thursday, Saturday. */
|
||||
readonly posts: readonly [PlannedPost, PlannedPost, PlannedPost];
|
||||
};
|
||||
|
||||
export const POSTING_DAYS = ["Tuesday", "Thursday", "Saturday"] as const;
|
||||
|
||||
export const PLAN: readonly PlannedWeek[] = [
|
||||
{
|
||||
topic: "Automation: orders",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationOrder" },
|
||||
{ kind: "image", id: "CardNotTechnical" },
|
||||
{ kind: "image", id: "PosterOrder" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Websites",
|
||||
posts: [
|
||||
{ kind: "video", id: "WebsiteDone" },
|
||||
{ kind: "image", id: "CardOneContact" },
|
||||
{ kind: "image", id: "PosterWebsite" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Automation: invoices",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationDocuments" },
|
||||
{ kind: "image", id: "CardHours" },
|
||||
{ kind: "image", id: "PosterDocuments" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Reliable apps",
|
||||
posts: [
|
||||
{ kind: "video", id: "AppRescue" },
|
||||
{ kind: "image", id: "CardOwnership" },
|
||||
{ kind: "image", id: "PosterApp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "Automation: enquiries",
|
||||
posts: [
|
||||
{ kind: "video", id: "AutomationEnquiry" },
|
||||
{ kind: "image", id: "CardNotMyField" },
|
||||
{ kind: "image", id: "PosterEnquiry" },
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: "A developer for your product",
|
||||
posts: [
|
||||
{ kind: "video", id: "ProductDeveloper" },
|
||||
{ kind: "image", id: "CardNotice" },
|
||||
{ kind: "image", id: "PosterDeveloper" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -83,15 +83,15 @@ export const COPY: Readonly<Record<VideoId, Readonly<Record<Lang, VideoCopy>>>>
|
||||
calm: ["And your team", "has time."],
|
||||
cta: ["Done with", "ping-pong?"],
|
||||
},
|
||||
post: `Copy, paste, repeat.
|
||||
post: `Looks like ping-pong. It's your office.
|
||||
|
||||
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.
|
||||
An order comes in. Someone opens it in the shop, writes the invoice, updates the customer record, sends the confirmation. Then the next one. And the next.
|
||||
|
||||
That works, until the orders pile up.
|
||||
Works beautifully, as long as hardly anyone orders.
|
||||
|
||||
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.
|
||||
The four tools can sort this out among themselves: the order triggers invoice, customer record and email on its own. Your team sees the finished result and looks after the cases that really need a person.
|
||||
|
||||
Is a process like this taking up your team's time? Write to me. A first call takes about 20 minutes.
|
||||
I connect the tools you already use. A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -134,15 +134,15 @@ www.mintel.me
|
||||
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.
|
||||
post: `Looks like a skyscraper. It's your inbox.
|
||||
|
||||
Every single one.
|
||||
Invoices arrive as PDF. Someone opens each one, reads supplier, amount and date, and types them into the accounting system. Floor by floor.
|
||||
|
||||
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.
|
||||
Nobody has to do that 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.
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -158,15 +158,15 @@ www.mintel.me
|
||||
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.
|
||||
post: `Sieht aus wie ein Hochhaus. Ist aber Ihr Posteingang.
|
||||
|
||||
Jede einzelne.
|
||||
Rechnungen kommen als PDF. Jemand öffnet jede einzelne, liest Lieferant, Betrag und Datum ab und tippt alles in die Buchhaltung. Stockwerk für Stockwerk.
|
||||
|
||||
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.
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -185,15 +185,15 @@ www.mintel.me
|
||||
calm: ["And follow-ups", "happen by themselves."],
|
||||
cta: ["Stop losing", "enquiries?"],
|
||||
},
|
||||
post: `Someone fills in your contact form. And then?
|
||||
post: `Looks like a star. It's an enquiry, burning out.
|
||||
|
||||
Often the enquiry is copied into the customer list by hand, forwarded by email, and everyone hopes the follow-up is not forgotten.
|
||||
Someone fills in your contact form. The enquiry is copied into the customer list by hand, forwarded by email, and everyone hopes the follow-up is not forgotten.
|
||||
|
||||
Hope is not a process.
|
||||
|
||||
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.
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -209,15 +209,15 @@ www.mintel.me
|
||||
calm: ["Und Nachfassen", "passiert von selbst."],
|
||||
cta: ["Keine Anfrage", "mehr verlieren?"],
|
||||
},
|
||||
post: `Jemand füllt Ihr Kontaktformular aus. Und dann?
|
||||
post: `Sieht aus wie ein Stern. Ist aber eine Anfrage, die gerade verglüht.
|
||||
|
||||
Oft wird die Anfrage von Hand in die Kundenliste kopiert, per E-Mail weitergeleitet, und alle hoffen, dass das Nachfassen nicht vergessen wird.
|
||||
Jemand füllt Ihr Kontaktformular aus. Die Anfrage wird von Hand in die Kundenliste kopiert, per E-Mail weitergeleitet, und alle hoffen, dass das Nachfassen nicht vergessen wird.
|
||||
|
||||
Hoffen ist kein Prozess.
|
||||
|
||||
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.
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -236,15 +236,15 @@ www.mintel.me
|
||||
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.
|
||||
post: `Looks like a beginning. It's been your website for months.
|
||||
|
||||
This is how I do it instead:
|
||||
You run a business. Learning a website builder on top of that was never going to happen.
|
||||
|
||||
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.
|
||||
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.
|
||||
And afterwards? A new photo or a changed price: you send me an email.
|
||||
|
||||
If your website has been on your list for too long, write to me. A first call takes about 20 minutes.
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -260,15 +260,15 @@ www.mintel.me
|
||||
calm: ["Passt auf", "jeden Bildschirm."],
|
||||
cta: ["Website", "fällig?"],
|
||||
},
|
||||
post: `Sie führen ein Unternehmen. Einen Website-Baukasten wollen Sie nicht auch noch lernen.
|
||||
post: `Sieht aus wie ein Anfang. Ist seit Monaten Ihre Website.
|
||||
|
||||
So mache ich es stattdessen:
|
||||
Sie führen ein Unternehmen. Dass Sie nebenbei noch einen Website-Baukasten lernen, war nie realistisch.
|
||||
|
||||
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.
|
||||
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.
|
||||
Und danach? Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.
|
||||
|
||||
Steht Ihre Website schon zu lange auf der Liste? Schreiben Sie mir. Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -287,7 +287,9 @@ www.mintel.me
|
||||
calm: ["After that", "it stands."],
|
||||
cta: ["App", "wobbling?"],
|
||||
},
|
||||
post: `Your app works. But bugs come back, and small changes take longer than they should.
|
||||
post: `Looks like Jenga. It's your app.
|
||||
|
||||
It works. But bugs come back, and every small change feels like pulling out a block.
|
||||
|
||||
That usually happens when an app was built quickly and then kept growing.
|
||||
|
||||
@@ -295,7 +297,7 @@ I take a week to go through it: I look at what is solid and what is fragile, fix
|
||||
|
||||
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.
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -311,15 +313,17 @@ www.mintel.me
|
||||
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.
|
||||
post: `Sieht aus wie Jenga. Ist aber Ihre App.
|
||||
|
||||
Sie läuft. Aber Fehler kommen wieder, und jede kleine Änderung fühlt sich an, als zöge man einen Stein heraus.
|
||||
|
||||
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.
|
||||
Ich nehme mir eine Woche 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.
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -338,15 +342,17 @@ www.mintel.me
|
||||
calm: ["Fixed days.", "No minimum term."],
|
||||
cta: ["Backlog", "too long?"],
|
||||
},
|
||||
post: `You have a product, and more work than your team can handle.
|
||||
post: `Looks like a loading bar. It's your backlog.
|
||||
|
||||
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.
|
||||
You have a product, and the list grows faster than your team gets through it.
|
||||
|
||||
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.
|
||||
I join on agreed days of the week and work on what is next: 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. No package to buy, 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.
|
||||
A first call takes about 20 minutes.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
@@ -362,15 +368,17 @@ www.mintel.me
|
||||
calm: ["Feste Tage.", "Keine Mindestlaufzeit."],
|
||||
cta: ["Backlog", "zu lang?"],
|
||||
},
|
||||
post: `Sie haben ein Produkt und mehr Arbeit, als Ihr Team schafft.
|
||||
post: `Sieht aus wie ein Ladebalken. Ist aber Ihr Backlog.
|
||||
|
||||
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.
|
||||
Sie haben ein Produkt, und die Liste wächst schneller, als Ihr Team sie abarbeitet.
|
||||
|
||||
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.
|
||||
Ich komme an vereinbarten Wochentagen dazu und arbeite an dem, was als Nächstes ansteht: 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. Kein Paket, keine Mindestlaufzeit: Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.
|
||||
|
||||
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.
|
||||
Ein erstes Gespräch dauert etwa 20 Minuten.
|
||||
|
||||
www.mintel.me
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ const blockY = (index: number): number => BLOCK.base - BLOCK.height / 2 - index
|
||||
const alignStart = (index: number): number => T.resolve + index * ALIGN_EVERY;
|
||||
const LAST_ALIGN = alignStart(TOP) + 22;
|
||||
|
||||
const Drawing: FC = () => {
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const AppDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 2.4, 1, { x: 0, y: blockY(TOP) + 30 });
|
||||
const { zoom, hair, others } = camera;
|
||||
@@ -101,6 +102,6 @@ const Drawing: FC = () => {
|
||||
/** 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 />
|
||||
<AppDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,8 @@ 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 = () => {
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const DeveloperDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 1.7, 1, { x: 0, y: rowY(0) });
|
||||
const { zoom, hair, others } = camera;
|
||||
@@ -88,6 +89,6 @@ const Drawing: FC = () => {
|
||||
/** 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 />
|
||||
<DeveloperDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
|
||||
@@ -43,7 +43,8 @@ const waitingSpot = (index: number): { x: number; y: number } => {
|
||||
return { x: PERSON.x + Math.cos(angle) * 78, y: PERSON.y + Math.sin(angle) * 78 };
|
||||
};
|
||||
|
||||
const Drawing: FC = () => {
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const DocumentsDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 2.6, 0.95, { x: BAR.x, y: barY(OPENING_BARS) - 20 });
|
||||
const { zoom, hair, others } = camera;
|
||||
@@ -123,6 +124,6 @@ const Drawing: FC = () => {
|
||||
/** Incoming invoices: from a growing pile to documents that are read and booked by themselves. */
|
||||
export const DocumentsFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<Drawing />
|
||||
<DocumentsDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
|
||||
@@ -43,7 +43,8 @@ const brightness = (index: number, frame: number): number => {
|
||||
return mix(1 - 0.88 * faded, 1, reached);
|
||||
};
|
||||
|
||||
const Drawing: FC = () => {
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const EnquiryDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 3, 0.95, HERO);
|
||||
const { zoom, hair, others } = camera;
|
||||
@@ -119,6 +120,6 @@ const Drawing: FC = () => {
|
||||
/** Enquiries: from stars that burn out unanswered to every one reaching the right person. */
|
||||
export const EnquiryFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<Drawing />
|
||||
<EnquiryDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ 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. */
|
||||
/** One statement: which text (key in the copy) and while which frames. */
|
||||
export type Caption = {
|
||||
readonly id: string;
|
||||
readonly from: number;
|
||||
@@ -18,7 +18,7 @@ export type Caption = {
|
||||
readonly button?: boolean;
|
||||
};
|
||||
|
||||
const SIDE = 60;
|
||||
export const SIDE = 60;
|
||||
const FOOTER_HEIGHT = 130;
|
||||
const MARK_HEIGHT = 76;
|
||||
const GAP = 44;
|
||||
@@ -28,7 +28,6 @@ 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)";
|
||||
|
||||
@@ -36,6 +35,28 @@ const clampOpts = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as con
|
||||
/** Fast start, long soft landing: the easing of a calm presentation. */
|
||||
const settle = Easing.bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/** Two or three lines in the headline type: the first ones light, the last one quieter. */
|
||||
export const Lines: FC<{ lines: readonly string[]; fontSize: number }> = ({ lines, fontSize }) => (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const Statement: FC<{ caption: Caption; first: boolean; fontSize: number; total: number }> = ({
|
||||
caption,
|
||||
first,
|
||||
@@ -60,22 +81,7 @@ const Statement: FC<{ caption: Caption; first: boolean; fontSize: number; total:
|
||||
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>
|
||||
))}
|
||||
<Lines lines={lines} fontSize={fontSize} />
|
||||
{caption.button && (
|
||||
<div style={{ marginTop: fontSize * 0.55 }}>
|
||||
<BrandButton label="Get in touch" size={Math.round(fontSize * 0.3)} tone="light" />
|
||||
@@ -86,29 +92,14 @@ const Statement: FC<{ caption: Caption; first: boolean; fontSize: number; total:
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The ground every film and image stands on: a quiet dark surface with one soft light, the
|
||||
* signature very large and faint behind everything, and a small line with signature and address.
|
||||
*/
|
||||
export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }> = ({ captions, children }) => {
|
||||
export const Backdrop: FC<{ children: ReactNode }> = ({ 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
|
||||
@@ -118,7 +109,6 @@ export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }
|
||||
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={{
|
||||
@@ -129,28 +119,7 @@ export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }
|
||||
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>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -179,3 +148,53 @@ export const FilmStage: FC<{ captions: readonly Caption[]; children: ReactNode }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What every film shares: the backdrop, the drawing in the middle, and one statement at a time
|
||||
* centred below it. 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 { 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),
|
||||
);
|
||||
|
||||
return (
|
||||
<Backdrop>
|
||||
<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>
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,7 +82,8 @@ const walkerAt = (row: number, frame: number): Point => {
|
||||
return hopAt(time - start, walker.route.map((col) => stop(row, col)), walker.hopFrames, ARC);
|
||||
};
|
||||
|
||||
const Drawing: FC = () => {
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const OrderDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const back = pulledBack(frame);
|
||||
const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back;
|
||||
@@ -190,6 +191,6 @@ const Drawing: FC = () => {
|
||||
/** Automation, as one continuous shot: from ping-pong to tools that talk to each other. */
|
||||
export const OrderFilm: FC = () => (
|
||||
<FilmStage captions={CAPTIONS}>
|
||||
<Drawing />
|
||||
<OrderDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
|
||||
@@ -79,7 +79,8 @@ const DEVICES = [
|
||||
{ x: 380, y: 50, width: 112, height: 210, columns: 1 },
|
||||
] as const;
|
||||
|
||||
const Drawing: FC = () => {
|
||||
/** The picture alone, without stage and statements: also used frozen, as an image post. */
|
||||
export const WebsiteDrawing: FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const camera = cameraAt(frame, 3, 1, CURSOR);
|
||||
const { hair, others } = camera;
|
||||
@@ -127,6 +128,6 @@ const Drawing: FC = () => {
|
||||
/** 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 />
|
||||
<WebsiteDrawing />
|
||||
</FilmStage>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FC } from "react";
|
||||
import { useVideoConfig } from "remotion";
|
||||
import { FONT } from "../brand/fonts";
|
||||
import { DRAW } from "../brand/tokens";
|
||||
import { CopyProvider } from "../content/CopyContext";
|
||||
import { IMAGES, type ImageCopy, type ImageId } from "../content/images";
|
||||
import type { Lang } from "../content/videos";
|
||||
import { Backdrop, FilmStage, Lines, SIDE, type Caption } from "../films/FilmStage";
|
||||
import { VIDEOS } from "../registry";
|
||||
import { headlineSize } from "../slides/SlideDeck";
|
||||
|
||||
const MAX_CARD_FONT = 132;
|
||||
/** The statement of a poster stands for good: an image has one frame. */
|
||||
const ALWAYS: readonly Caption[] = [{ id: "poster", from: 0, to: 1_000_000 }];
|
||||
|
||||
/**
|
||||
* One picture of a film, with the film's idea in two lines below it. Which moment is shown is
|
||||
* decided by the frame the composition is rendered at (see Root and the export script).
|
||||
*/
|
||||
const Poster: FC<{ video: string; copy: ImageCopy; lang: Lang }> = ({ video, copy, lang }) => {
|
||||
const entry = VIDEOS.find((candidate) => candidate.id === video);
|
||||
if (!entry || !("Drawing" in entry)) throw new Error(`No film with a drawing is registered as "${video}"`);
|
||||
return (
|
||||
<CopyProvider value={{ lang, copy: { title: copy.title, slides: { poster: copy.lines }, post: copy.post } }}>
|
||||
<FilmStage captions={ALWAYS}>
|
||||
<entry.Drawing />
|
||||
</FilmStage>
|
||||
</CopyProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/** A question clients ask, small, and its answer in very large type. Nothing else. */
|
||||
const Card: FC<{ copy: ImageCopy }> = ({ copy }) => {
|
||||
const { width } = useVideoConfig();
|
||||
const fontSize = Math.min(MAX_CARD_FONT, headlineSize(copy.lines, width - SIDE * 2));
|
||||
return (
|
||||
<Backdrop>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: `0 ${SIDE}px 150px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 22,
|
||||
backgroundColor: DRAW.mark,
|
||||
boxShadow: `0 0 28px 6px ${DRAW.mark}`,
|
||||
marginBottom: fontSize * 0.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT.mono,
|
||||
fontWeight: 500,
|
||||
fontSize: Math.round(fontSize * 0.28),
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: DRAW.mark,
|
||||
marginBottom: fontSize * 0.45,
|
||||
}}
|
||||
>
|
||||
{copy.label}
|
||||
</div>
|
||||
<Lines lines={copy.lines} fontSize={fontSize} />
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
};
|
||||
|
||||
/** One image post in one language: a poster of a film, or a card. */
|
||||
export const ImagePost: FC<{ id: ImageId; lang: Lang }> = ({ id, lang }) => {
|
||||
const image = IMAGES[id];
|
||||
const copy = image.copy[lang];
|
||||
return image.poster ? (
|
||||
<Poster video={image.poster.video} copy={copy} lang={lang} />
|
||||
) : (
|
||||
<Card copy={copy} />
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
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 type { VideoEntry } from "./slides/ServiceVideo";
|
||||
|
||||
/** Register new videos here. Each one is offered as `<id>-<language>-<format>`. */
|
||||
export const VIDEOS: readonly VideoEntry[] = [
|
||||
automationOrder,
|
||||
automationDocuments,
|
||||
automationEnquiry,
|
||||
websiteDone,
|
||||
appRescue,
|
||||
productDeveloper,
|
||||
];
|
||||
@@ -10,7 +10,13 @@ import { SlideDeck, deckDuration, type Slide } from "./SlideDeck";
|
||||
*/
|
||||
export type VideoEntry =
|
||||
| { readonly id: VideoId; readonly slides: readonly Slide[] }
|
||||
| { readonly id: VideoId; readonly Film: FC; readonly durationInFrames: number };
|
||||
| {
|
||||
readonly id: VideoId;
|
||||
readonly Film: FC;
|
||||
/** The picture of the film alone, in stage coordinates. */
|
||||
readonly Drawing: FC;
|
||||
readonly durationInFrames: number;
|
||||
};
|
||||
|
||||
export const videoDuration = (video: VideoEntry): number =>
|
||||
"Film" in video ? video.durationInFrames : deckDuration(video.slides);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Content strategy: LinkedIn first
|
||||
|
||||
Status: proposal for the owner's review. Nothing has been posted or scheduled. The material exists:
|
||||
six films and twelve image posts, each in German and English, with the text to post
|
||||
(`apps/video`, rendered by `pnpm library` into `out/library`).
|
||||
|
||||
No reach or engagement figures are quoted here: there are none for this account yet. Section 6 says
|
||||
what to measure, and what to change once there are numbers.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Enquiries by email from people who need one of the four services. Not followers, not likes.
|
||||
Every post leads to one action: write to me, via `www.mintel.me`.
|
||||
|
||||
## 2. Who it is for
|
||||
|
||||
| Audience | What they recognise | Films |
|
||||
| :------- | :------------------ | :---- |
|
||||
| Owners and managers of small and mid-sized companies, not technical | Work done by hand between tools; a website that never gets done | Orders, invoices, enquiries, website |
|
||||
| Founders and product leads | An app that has become fragile; a backlog that outgrows the team | Reliable apps, developer |
|
||||
|
||||
German is the main language, as on the website. The English versions are for an English-speaking
|
||||
network and for the developer topic, where teams often work in English.
|
||||
|
||||
## 3. The idea that holds it together
|
||||
|
||||
Every post starts from the same move: something that **looks like** one thing and **is** another.
|
||||
"Looks like ping-pong. It's your office." The viewer gets a small riddle, then recognises their own
|
||||
situation, then sees it resolved. That is the hook, the tone (a little dry, never mocking the
|
||||
client) and the visual language at once: squares, dots, lines, nothing else.
|
||||
|
||||
Three kinds of post carry it:
|
||||
|
||||
| Kind | What it is | Job |
|
||||
| :--- | :--------- | :-- |
|
||||
| Film (6) | 23 seconds, one shot, loops | Gets attention, explains one service to a layperson |
|
||||
| Poster (6) | One picture of the film with two lines: "Not ping-pong. Your office." | Repeats the idea for those who scrolled past the film |
|
||||
| Card (6) | A question clients ask before the first call, and the answer in large type | Removes a reason not to write: lock-in, ownership, "I am not technical" |
|
||||
|
||||
The cards say only what the website says (questions and principles in `apps/web/src/content`).
|
||||
|
||||
## 4. Rhythm
|
||||
|
||||
Proposal: **three posts a week for six weeks**, one topic per week.
|
||||
|
||||
- Tuesday: the film.
|
||||
- Thursday: a card.
|
||||
- Saturday: the poster of the same film.
|
||||
|
||||
This is more than the one post a week in the relaunch concept. The reasoning: one topic is seen
|
||||
three times in a week in three forms, without repeating a post. If three is too many, post only
|
||||
the films weekly and use posters and cards as the second round: the same material then lasts
|
||||
eighteen weeks. Which of the two is the owner's decision.
|
||||
|
||||
Order of topics: automation and websites first (the widest audience and the two planned ad
|
||||
campaigns), the two developer topics later.
|
||||
|
||||
| Week | Topic | Tuesday | Thursday | Saturday |
|
||||
| :--- | :---- | :------ | :------- | :------- |
|
||||
| 1 | Automation: orders | Film "ping-pong" | Card "Not a technical person?" | Poster |
|
||||
| 2 | Websites | Film "a beginning" | Card "Who does the work?" | Poster |
|
||||
| 3 | Automation: invoices | Film "skyscraper" | Card "What do you pay for?" | Poster |
|
||||
| 4 | Reliable apps | Film "Jenga" | Card "Who owns the code?" | Poster |
|
||||
| 5 | Automation: enquiries | Film "a star" | Card "What I do not do" | Poster |
|
||||
| 6 | A developer for your product | Film "loading bar" | Card "How long are you committed?" | Poster |
|
||||
|
||||
The plan is data in `apps/video/src/content/plan.ts`; the overview page `out/library/index.html`
|
||||
shows the posts in this order, with the text to copy and the files to download.
|
||||
|
||||
## 5. How to post
|
||||
|
||||
- Upload the film or image directly to LinkedIn (no link to a video elsewhere), 4:5 format.
|
||||
- The post text opens with the hook of the film. Keep it as written, or shorten it; do not add claims.
|
||||
- The address is in the text and in the picture. If a link is wanted, put it in the first comment.
|
||||
- Answer every comment and message personally. That is where enquiries start.
|
||||
- 16:9 versions are for YouTube and, later, for ads shown to people who already visited the site
|
||||
(see `google-ads.md`).
|
||||
|
||||
## 6. What to measure, and when to change something
|
||||
|
||||
Per post, noted once a week: impressions, comments, profile visits, and above all enquiries by
|
||||
email that mention LinkedIn or follow a post. Clicks on the address are counted on the website
|
||||
once the click event exists (see `google-ads.md`, prerequisite 2).
|
||||
|
||||
After three weeks:
|
||||
- A topic that brought an enquiry or real conversations gets a second film with another example.
|
||||
- A kind of post that is consistently ignored is dropped in favour of the others.
|
||||
- If nothing moves, the rhythm is not the problem: look at who sees the posts (network) first.
|
||||
|
||||
## 7. What comes after the six weeks
|
||||
|
||||
- New examples for the topic that worked, made with the `video-film` skill.
|
||||
- Posts from real work, once a client agrees: what the problem was, what changed. These need the
|
||||
client's consent and are not prepared.
|
||||
- The same material on other channels (X, YouTube) only if LinkedIn shows which topic works.
|
||||
|
||||
## 8. Open decisions for the owner
|
||||
|
||||
1. Three posts a week, or one?
|
||||
2. German only, or both languages (and if both: one account, alternating, or German only on LinkedIn)?
|
||||
3. The offer "make an app reliable" is posted in week 4 without a price. Is the offer final?
|
||||
@@ -98,7 +98,7 @@ 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. 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`).
|
||||
2. LinkedIn first; plan, rhythm and measures in `plans/content-strategy.md`. 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.
|
||||
|
||||
Reference in New Issue
Block a user