Files
mintel.me/apps/video/scripts/export.ts
T
mmintelandClaude Opus 5.5 4bfbcbcacd feat: video films for social media, with the skill to make more
Adds apps/video, a standalone Remotion package (not part of the pnpm
workspace, so it never reaches the web build or the Docker image). It
renders six looping films, one per service, in German and English, as
4:5 for LinkedIn and 16:9 for YouTube and ads, together with the text
to post and an overview page.

Each film is one continuous shot: close on something abstract, the
camera pulls back to show what it really is, the form changes, and the
picture folds back into its opening. The slide deck code from the
earlier attempts is still in the package and no longer used.

Also adds the Google Ads concept, brings the relaunch concept in line
with the site as built, and a project skill that describes how to make
further films.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:23:01 +02:00

138 lines
6.4 KiB
TypeScript

/**
* 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")}`);