Files
DIYgod cdd3942d3d feat(store-assets): render release promo videos from a spec
Turn the one-off script behind the desktop 1.15.0 / mobile 0.5.11
announcement video into store-assets/scripts/release-video.ts, driven by
a JSON spec; promo/example.json reproduces that video and uses every
layout. Stills mode writes one frame per scene plus a contact sheet,
both modes print each shot's capture time and report copy that wraps or
leaves its area, and headlines shrink to fit the copy column.

Stacked Mac shots now get an opaque fill: the captures leave the
vibrancy sidebar transparent, so a crossfade showed the sidebars of the
shots below through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:35:45 +08:00

672 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Renders a release announcement video (1920 × 1080, 30 fps, H.264, no sound) from a JSON
// spec and the app captures, in the look of the store slides: an intro with the versions,
// one scene per highlight and an outro with the download link.
// tsx store-assets/scripts/release-video.ts <spec.json> --stills <dir> [--captures <dir>]
// tsx store-assets/scripts/release-video.ts <spec.json> --video <out.mp4> [--captures <dir>]
// The spec is a VideoSpec (below); promo/example.json uses every layout. Shots name captures
// without the extension, e.g. "en/mac/hero", and the device folder picks the frame.
// Both modes print the timeline and each shot's capture time, and report copy that wraps or
// leaves its area. --stills writes one frame per scene plus contact-sheet.png and exits 1 on
// problems; --video renders nothing until they are fixed, and needs ffmpeg.
// The page is one HTML document of CSS animations. Each frame pauses every animation at the
// frame's time, takes a screenshot and pipes it to ffmpeg, so the output is frame-exact
// however long a frame takes to render.
import { spawn, spawnSync } from "node:child_process"
import { once } from "node:events"
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { pathToFileURL } from "node:url"
import { join, resolve } from "pathe"
import type { Page } from "playwright"
import { chromium } from "playwright"
import sharp from "sharp"
import { brand, googleFontsHref } from "../src/brand"
import type { Rect } from "../src/callout"
import { readCallout } from "../src/callout"
import type { Capture } from "../src/frames"
import { handheldFrame } from "../src/frames"
import { foloLogo, icon } from "../src/icons"
type Visual =
// A Mac window; several shots crossfade in order.
| { layout: "mac"; mac: string | string[] }
// A Mac window next to a phone, for changes that span devices.
| { layout: "mac-phone"; mac: string; phone: string; badge?: { icon: string; spin?: boolean } }
// One or two phones. `callout` names a shot whose <shot>.json sidecar marks a region (the
// player bar of `listen`, the AI card of `summary`) to enlarge in front of them.
| { layout: "phones"; phones: string[]; callout?: string }
interface SceneSpec {
// Time on screen in ms, 4600 by default; a Mac crossfade gets at least what it needs.
duration?: number
// A MingCute icon name and a few words naming the area.
eyebrow: { icon: string; text: string }
// One or two short lines; *text* gets the accent gradient. The type shrinks until every
// line fits the copy column.
headline: string[]
// One sentence under the headline.
sub?: string
// Names lit one by one along with the Mac crossfade, then all together (languages).
chips?: string[]
// A check list, one line per item (fixes).
items?: string[]
visual: Visual
}
interface VideoSpec {
// The released versions; leave one out for a single-app release.
desktop?: string
mobile?: string
scenes: SceneSpec[]
}
interface Shot extends Capture {
device: string
takenAt: Date
}
interface Slot {
name: string
start: number
end: number
}
const W = 1920
const H = 1080
const FPS = 30
const INTRO = 3200
const OUTRO = 3200
const SCENE = 4600
// Time between the steps of a Mac crossfade.
const STEP = 1400
const FADE_OUT = 450
// Copy stays left of this line; the devices start right of it.
const COPY_RIGHT = 840
// Text keeps this distance from the canvas edges.
const SAFE = 40
const root = join(import.meta.dirname, "..")
const args = process.argv.slice(2)
const option = (name: string) => {
const index = args.indexOf(`--${name}`)
return index === -1 ? null : (args[index + 1] ?? null)
}
const specPath = args[0]
const stillsDir = option("stills")
const videoPath = option("video")
const capturesDir = resolve(option("captures") ?? join(root, "captures"))
if (!specPath || specPath.startsWith("--") || (!stillsDir && !videoPath)) {
throw new Error(
"Usage: release-video.ts <spec.json> (--stills <dir> | --video <out.mp4>) [--captures <dir>]",
)
}
const spec = JSON.parse(await readFile(specPath, "utf8")) as VideoSpec
const versions: { icon: string; label: string; version: string }[] = []
if (spec.desktop) versions.push({ icon: "computer-line", label: "Desktop", version: spec.desktop })
if (spec.mobile) versions.push({ icon: "cellphone-line", label: "Mobile", version: spec.mobile })
if (versions.length === 0) throw new Error("The spec names no desktop or mobile version")
if (spec.scenes.length === 0) throw new Error("The spec has no scenes")
const deviceOf = (ref: string) => ref.split("/")[1] ?? ""
const expectDevice = (ref: string, devices: string[]) => {
if (!devices.includes(deviceOf(ref))) {
throw new Error(`${ref}: expected a ${devices.join(" or ")} capture`)
}
return ref
}
const phoneDevices = ["iphone", "android"]
const macShots = (visual: Visual) => (visual.layout === "mac" ? [visual.mac].flat() : [])
const shotRefs = (visual: Visual): string[] => {
switch (visual.layout) {
case "mac": {
return macShots(visual).map((ref) => expectDevice(ref, ["mac"]))
}
case "mac-phone": {
return [expectDevice(visual.mac, ["mac"]), expectDevice(visual.phone, phoneDevices)]
}
case "phones": {
if (visual.phones.length < 1 || visual.phones.length > 2) {
throw new Error(`The phones layout takes one or two phones, not ${visual.phones.length}`)
}
const refs = visual.phones.map((ref) => expectDevice(ref, phoneDevices))
return visual.callout ? [...refs, expectDevice(visual.callout, phoneDevices)] : refs
}
default: {
throw new Error(`Unknown visual ${JSON.stringify(visual)}`)
}
}
}
for (const [i, scene] of spec.scenes.entries()) {
if (scene.headline.length < 1 || scene.headline.length > 2) {
throw new Error(`Scene ${i + 1}: the headline takes one or two lines`)
}
}
const localTime = (date: Date) => {
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const shots = new Map<string, Shot>()
const missing: string[] = []
for (const ref of new Set(spec.scenes.flatMap((scene) => shotRefs(scene.visual)))) {
const path = join(capturesDir, `${ref}.png`)
const info = await stat(path).catch(() => null)
if (!info) {
missing.push(path)
continue
}
const { width, height } = await sharp(path).metadata()
shots.set(ref, {
url: pathToFileURL(path).href,
width: width!,
height: height!,
device: deviceOf(ref),
takenAt: info.mtime,
})
}
if (missing.length > 0) throw new Error(`Missing captures:\n${missing.join("\n")}`)
const shot = (ref: string) => shots.get(ref)!
const callouts = new Map<string, Rect>()
for (const scene of spec.scenes) {
if (scene.visual.layout !== "phones" || !scene.visual.callout) continue
const ref = scene.visual.callout
const rect = await readCallout(join(capturesDir, `${ref}.json`))
if (!rect) throw new Error(`${ref} has no callout region (${ref}.json)`)
callouts.set(ref, rect)
}
const sceneLength = (scene: SceneSpec) => {
const steps = macShots(scene.visual).length
return Math.max(scene.duration ?? SCENE, steps > 1 ? 1300 + steps * STEP : 0)
}
const slug = (text: string) =>
text
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/^-|-$/g, "")
const lengths: [string, number][] = [
["intro", INTRO],
...spec.scenes.map((scene): [string, number] => [
slug(scene.eyebrow.text) || "scene",
sceneLength(scene),
]),
["outro", OUTRO],
]
const slots: Slot[] = []
let clock = 0
for (const [name, length] of lengths) {
slots.push({ name, start: clock, end: clock + length })
clock += length
}
const TOTAL = clock
// --- markup ------------------------------------------------------------------
const escapeHtml = (text: string) =>
text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
const rich = (text: string) => escapeHtml(text).replaceAll(/\*([^*]+)\*/g, "<em>$1</em>")
const enter = (name: string, start: number, duration: number) =>
`${name} ${duration}ms cubic-bezier(.16,1,.3,1) ${start}ms both`
const section = (slot: Slot, body: string) => {
const animations = [`show 1ms linear ${slot.start}ms both`]
if (slot.end < TOTAL) {
animations.push(
`fade-out ${FADE_OUT}ms cubic-bezier(.7,0,.84,0) ${slot.end - FADE_OUT}ms forwards`,
)
}
return `<section class="scene" style="animation:${animations.join(",")}">${body}</section>`
}
// macOS window like macWindow in frames.ts, with stacked shots that can crossfade. The
// captures leave the vibrancy sidebar transparent, so every layer gets an opaque fill;
// otherwise the sidebars of the shots below show through.
const macStack = (layers: { shot: Shot; animation?: string }[], outerWidth: number) => {
const first = layers[0]!.shot
const k = outerWidth / (first.width / 2)
const height = (outerWidth * first.height) / first.width
const radius = 16 * k
const hairline = Math.max(1, 0.5 * k)
const light = (color: string, x: number) =>
`<div style="position:absolute;left:${x * k}px;top:${18 * k}px;width:${12 * k}px;height:${12 * k}px;border-radius:50%;background:${color};box-shadow:inset 0 0 0 ${0.5 * k}px rgba(0,0,0,.18)"></div>`
const images = layers
.map(
(layer) =>
`<img src="${layer.shot.url}" style="position:absolute;inset:0;display:block;width:100%;height:100%;background:#f6f2ef${layer.animation ? `;animation:${layer.animation}` : ""}" />`,
)
.join("")
const html = `
<div class="device" style="position:relative;width:${outerWidth}px;height:${height}px">
<div style="position:absolute;inset:0;border-radius:${radius}px;background:rgba(246,242,239,.92);box-shadow:0 ${70 * k}px ${140 * k}px -${30 * k}px rgba(76,36,10,.34), 0 ${18 * k}px ${44 * k}px rgba(76,36,10,.14), 0 0 0 ${hairline}px rgba(0,0,0,.12)"></div>
<div style="position:absolute;inset:0;border-radius:${radius}px;overflow:hidden">${images}</div>
${light("#FF5F57", 18)}${light("#FEBC2E", 38)}${light("#28C840", 58)}
<div style="position:absolute;inset:0;border-radius:${radius}px;box-shadow:inset 0 0 0 ${hairline}px rgba(255,255,255,.55);pointer-events:none"></div>
</div>`
return { html, height }
}
const phoneFrame = (ref: string, width: number) => {
const capture = shot(ref)
return handheldFrame(
capture.device === "iphone" ? "iphone" : "android-phone",
capture,
width,
"light",
)
}
// Enlarges the sidecar region of a phone capture, centered on centerX near the bottom.
const callout = (ref: string, start: number, centerX: number) => {
const capture = shot(ref)
const rect = callouts.get(ref)!
const regionWidth = rect.width * capture.width
const regionHeight = rect.height * capture.height
const width = Math.min(660, (360 * regionWidth) / regionHeight)
const scale = width / regionWidth
const height = regionHeight * scale
const top = Math.min(860, H - 110 - height)
return `<div class="abs callout" style="left:${centerX - width / 2}px;top:${top}px;width:${width}px;height:${height}px;background-image:url('${capture.url}');background-size:${capture.width * scale}px ${capture.height * scale}px;background-position:-${rect.x * capture.width * scale}px -${rect.y * capture.height * scale}px;animation:${enter("pop", start + 1500, 900)},glow 1800ms ease-in-out ${start + 2400}ms 2"></div>`
}
// The devices on the right half of a scene.
const stage = (scene: SceneSpec, slot: Slot, steps: number[]) => {
const { start, end } = slot
const drift = `drift ${end - start}ms linear ${start}ms both`
const { visual } = scene
switch (visual.layout) {
case "mac": {
const layers = macShots(visual).map((ref, i) => ({
shot: shot(ref),
animation: i === 0 ? undefined : `fade-in 600ms ease ${steps[i]}ms both`,
}))
const mac = macStack(layers, 1000)
return `<div class="abs" style="left:860px;top:${(H - mac.height) / 2 - 30}px;animation:${enter("in-right", start + 250, 1100)}"><div style="animation:${drift}">${mac.html}</div></div>`
}
case "mac-phone": {
const mac = macStack([{ shot: shot(visual.mac) }], 740)
const phone = phoneFrame(visual.phone, 260)
const badge = visual.badge
? `<div class="abs badge" style="left:1556px;top:${H / 2 - 56}px;animation:${enter("pop", start + 1000, 800)}"><div style="${visual.badge.spin ? `animation:spin 2400ms linear ${start}ms infinite` : ""}">${icon(visual.badge.icon, 56, "#fff")}</div></div>`
: ""
return `
<div class="abs" style="left:850px;top:${(H - mac.height) / 2}px;animation:${enter("in-right", start + 250, 1100)}"><div style="animation:${drift}">${mac.html}</div></div>
<div class="abs" style="left:1630px;top:${(H - phone.height) / 2}px;animation:${enter("in-up", start + 550, 1100)}">${phone.html}</div>
${badge}`
}
case "phones": {
// Two phones overlap the right half; a single one sits in its middle.
const places =
visual.phones.length === 1
? [{ left: 1180, width: 400, lift: 30 }]
: [
{ left: 870, width: 350, lift: 15 },
{ left: 1300, width: 360, lift: 40 },
]
const phones = visual.phones
.map((ref, i) => {
const place = places[i]!
const phone = phoneFrame(ref, place.width)
return `<div class="abs" style="left:${place.left}px;top:${(H - phone.height) / 2 - place.lift}px;animation:${enter("in-up", start + 300 + i * 150, 1100)}">${phone.html}</div>`
})
.join("")
const centerX = visual.phones.length === 1 ? 1380 : 1120
return phones + (visual.callout ? callout(visual.callout, start, centerX) : "")
}
default: {
throw new Error(`Unknown visual ${JSON.stringify(visual)}`)
}
}
}
const featureScene = (scene: SceneSpec, slot: Slot) => {
const { start } = slot
// The first Mac shot shows from the start; shot i fades in at steps[i], and its chip
// lights up at the same time. After the last step every chip lights up.
const stepCount = macShots(scene.visual).length
const steps = Array.from({ length: stepCount }, (_, i) =>
i === 0 ? start + 300 : start + 500 + i * STEP,
)
const allOn = stepCount > 1 ? start + 300 + stepCount * STEP : start + 1500
const chip = (label: string, i: number) => {
const animations = [enter("in-up", start + 700, 800)]
if (stepCount > 1 && i < stepCount) {
animations.push(`chip-on 300ms ease ${steps[i]}ms both`)
if (i < stepCount - 1) {
animations.push(
`chip-off 300ms ease ${steps[i + 1]}ms forwards`,
`chip-on 300ms ease ${allOn}ms forwards`,
)
}
} else {
animations.push(`chip-on 300ms ease ${allOn}ms both`)
}
return `<span class="chip" style="animation:${animations.join(",")}"><span class="t">${escapeHtml(label)}</span></span>`
}
const copy = [
`<div class="eyebrow" style="animation:${enter("in-up", start + 100, 800)}">${icon(scene.eyebrow.icon, 34, brand.accent)}<span class="t">${escapeHtml(scene.eyebrow.text)}</span></div>`,
`<h1 class="headline" style="font-size:92px">${scene.headline
.map(
(line, i) =>
`<span class="line" style="animation:${enter("in-up", start + 220 + i * 110, 900)}"><span class="t">${rich(line)}</span></span>`,
)
.join("")}</h1>`,
scene.sub
? `<p class="sub t-wrap" style="animation:${enter("in-up", start + 520, 900)}">${rich(scene.sub)}</p>`
: "",
scene.chips?.length ? `<div class="chips">${scene.chips.map(chip).join("")}</div>` : "",
scene.items?.length
? `<ul class="items">${scene.items
.map(
(item, i) =>
`<li style="animation:${enter("in-up", start + 700 + i * 140, 700)}">${icon("check-line", 30, brand.accent)}<span class="t">${rich(item)}</span></li>`,
)
.join("")}</ul>`
: "",
].join("")
return section(slot, `<div class="copy">${copy}</div>${stage(scene, slot, steps)}`)
}
const intro = (slot: Slot) => {
const { start } = slot
const pills = versions
.map(
(v, i) =>
`<div class="pill" style="animation:${enter("in-up", start + 750 + i * 120, 800)}">${icon(v.icon, 34, brand.accent)}<span class="t">${v.label} <b>v${escapeHtml(v.version)}</b></span></div>`,
)
.join("")
return section(
slot,
`
<div class="center">
<div style="animation:${enter("pop", start + 100, 900)}">${foloLogo(150)}</div>
<h1 class="headline" style="font-size:104px;margin-top:44px"><span class="line" style="animation:${enter("in-up", start + 380, 900)}"><span class="t">Folo update <em>time.</em></span></span></h1>
<div class="pills" style="margin-top:40px">${pills}</div>
</div>`,
)
}
const outro = (slot: Slot) => {
const { start } = slot
const line = versions.map((v) => `${v.label} v${escapeHtml(v.version)}`).join(" · ")
return section(
slot,
`
<div class="center">
<div style="animation:${enter("pop", start + 100, 900)}">${foloLogo(140)}</div>
<h1 class="headline" style="font-size:112px;margin-top:40px"><span class="line" style="animation:${enter("in-up", start + 350, 900)}"><span class="t">Update <em>today.</em></span></span></h1>
<p class="sub" style="font-size:40px;margin-top:22px;animation:${enter("in-up", start + 650, 900)}"><span class="t">folo.is</span></p>
<p class="small" style="animation:${enter("in-up", start + 850, 900)}"><span class="t">${line}</span></p>
</div>`,
)
}
const pageHtml = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<link rel="stylesheet" href="${googleFontsHref}">
<style>
* { box-sizing: border-box; margin: 0; padding: 0 }
html, body { width: ${W}px; height: ${H}px; overflow: hidden }
body { font-family: "Geist", "Noto Sans SC", "Noto Sans TC", "Noto Sans JP", "Noto Sans KR", sans-serif; color: ${brand.ink}; background: ${brand.paper}; -webkit-font-smoothing: antialiased }
.bg { position: absolute; inset: 0; overflow: hidden;
background: radial-gradient(1100px 800px at 92% 4%, rgba(255,92,0,.16), transparent 70%),
radial-gradient(900px 700px at 6% 10%, rgba(255,184,130,.20), transparent 70%),
linear-gradient(180deg, ${brand.paper} 0%, ${brand.paperDeep} 100%) }
.blob { position: absolute; border-radius: 50%; filter: blur(80px) }
.scene { position: absolute; inset: 0 }
.abs { position: absolute }
.copy { position: absolute; left: 120px; top: 0; bottom: 0; width: 700px; display: flex; flex-direction: column; justify-content: center }
.center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center }
.eyebrow { display: flex; align-items: center; gap: 14px; color: ${brand.accent}; font-weight: 600; font-size: 32px; margin-bottom: 26px }
.headline { font-weight: 760; line-height: 1.04; letter-spacing: -0.03em }
.headline .line { display: block }
.headline em { font-style: normal; background: linear-gradient(92deg, ${brand.accent} 0%, ${brand.accentSoft} 100%); -webkit-background-clip: text; background-clip: text; color: transparent }
.sub { margin-top: 30px; font-size: 32px; line-height: 1.42; color: ${brand.inkSecondary}; max-width: 640px }
.sub em { font-style: normal; color: ${brand.accent} }
.small { margin-top: 18px; font-size: 26px; color: ${brand.inkTertiary} }
.pills { display: flex; gap: 22px }
.pill { display: flex; align-items: center; gap: 14px; padding: 18px 30px; border-radius: 999px; background: rgba(255,255,255,.75); box-shadow: 0 10px 30px rgba(76,36,10,.10), inset 0 0 0 1px rgba(255,92,0,.14); font-size: 34px; color: ${brand.inkSecondary} }
.pill b { color: ${brand.ink}; font-weight: 700 }
.chips { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 34px }
.chip { padding: 12px 24px; border-radius: 999px; font-size: 30px; font-weight: 600; background: rgba(255,255,255,.7); color: ${brand.inkSecondary}; box-shadow: inset 0 0 0 1px rgba(27,21,18,.08) }
.items { list-style: none; margin-top: 36px; display: flex; flex-direction: column; gap: 16px }
.items li { display: flex; align-items: center; gap: 16px; font-size: 30px; color: ${brand.inkSecondary} }
.items em { font-style: normal; color: ${brand.ink} }
.badge { width: 112px; height: 112px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, ${brand.accent}, ${brand.accentSoft}); box-shadow: 0 18px 40px rgba(255,92,0,.35), 0 0 0 10px rgba(255,255,255,.7) }
.callout { border-radius: 26px; box-shadow: 0 30px 60px -10px rgba(76,36,10,.35), 0 0 0 1px rgba(0,0,0,.06) }
@keyframes show { from { opacity: 0 } to { opacity: 1 } }
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes fade-out { from { opacity: 1 } to { opacity: 0; transform: translateY(-24px) } }
@keyframes in-up { from { opacity: 0; transform: translateY(46px) } to { opacity: 1; transform: none } }
@keyframes in-right { from { opacity: 0; transform: translateX(160px) } to { opacity: 1; transform: none } }
@keyframes pop { 0% { opacity: 0; transform: scale(.6) } 60% { opacity: 1; transform: scale(1.05) } 100% { opacity: 1; transform: scale(1) } }
@keyframes drift { from { transform: scale(1) } to { transform: scale(1.035) } }
@keyframes spin { to { transform: rotate(360deg) } }
@keyframes glow { 0%, 100% { box-shadow: 0 30px 60px -10px rgba(76,36,10,.35), 0 0 0 0 rgba(255,92,0,0) } 50% { box-shadow: 0 30px 60px -10px rgba(76,36,10,.35), 0 0 0 14px rgba(255,92,0,.22) } }
@keyframes chip-on { to { background: ${brand.accent}; color: #fff; box-shadow: 0 10px 24px rgba(255,92,0,.3) } }
@keyframes chip-off { to { background: rgba(255,255,255,.7); color: ${brand.inkSecondary}; box-shadow: inset 0 0 0 1px rgba(27,21,18,.08) } }
@keyframes blob-a { from { transform: translate(0,0) } to { transform: translate(-260px, 160px) } }
@keyframes blob-b { from { transform: translate(0,0) } to { transform: translate(220px, -120px) } }
</style></head>
<body>
<div class="bg">
<div class="blob" style="width:760px;height:760px;right:-160px;top:-260px;background:rgba(255,92,0,.20);animation:blob-a ${TOTAL}ms linear 0ms both"></div>
<div class="blob" style="width:620px;height:620px;left:-200px;bottom:-240px;background:rgba(255,178,115,.28);animation:blob-b ${TOTAL}ms linear 0ms both"></div>
</div>
${intro(slots[0]!)}
${spec.scenes.map((scene, i) => featureScene(scene, slots[i + 1]!)).join("\n")}
${outro(slots.at(-1)!)}
</body></html>`
// --- rendering ---------------------------------------------------------------
const seek = (pg: Page, ms: number) =>
pg.evaluate(async (time) => {
for (const animation of document.getAnimations()) animation.currentTime = time
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))
}, ms)
// Text runs marked .t must stay on one line, .t-wrap may wrap; copy stays in its column
// and all text inside the safe area.
const findProblems = (pg: Page) =>
pg.evaluate(
({ width, height, copyRight, safe }) => {
const problems: string[] = []
for (const el of document.querySelectorAll<HTMLElement>(".t, .t-wrap")) {
const scene = el.closest<HTMLElement>(".scene")
if (!scene || Number(getComputedStyle(scene).opacity) < 0.5) continue
const range = document.createRange()
range.selectNodeContents(el)
const rects = [...range.getClientRects()].filter((rect) => rect.width > 0)
if (rects.length === 0) continue
const label = JSON.stringify((el.textContent ?? "").trim().slice(0, 48))
// Fragments more than 8px apart vertically sit on different lines.
const tops = rects.map((rect) => rect.top).sort((a, b) => a - b)
const lines = 1 + tops.filter((t, i) => i > 0 && t - tops[i - 1]! > 8).length
const left = Math.min(...rects.map((rect) => rect.left))
const right = Math.max(...rects.map((rect) => rect.right))
const top = Math.min(...rects.map((rect) => rect.top))
const bottom = Math.max(...rects.map((rect) => rect.bottom))
if (el.classList.contains("t") && lines > 1) problems.push(`${label} wraps`)
if (el.closest(".copy") && right > copyRight)
problems.push(`${label} leaves the copy column`)
if (left < safe || top < safe || right > width - safe || bottom > height - safe) {
problems.push(`${label} leaves the safe area`)
}
}
return problems
},
{ width: W, height: H, copyRight: COPY_RIGHT, safe: SAFE },
)
// Every scene has settled half a second before it ends.
const stillAt = (slot: Slot) => slot.end - 500
const checkAll = async (pg: Page) => {
const problems: string[] = []
for (const slot of slots) {
await seek(pg, stillAt(slot))
problems.push(...(await findProblems(pg)).map((problem) => `${slot.name}: ${problem}`))
}
return problems
}
const contactSheet = async (paths: string[], out: string) => {
const [thumbW, thumbH, gap, columns] = [640, 360, 16, 3]
const rows = Math.ceil(paths.length / columns)
const thumbs = await Promise.all(
paths.map((path) => sharp(path).resize(thumbW, thumbH).png().toBuffer()),
)
await sharp({
create: {
width: columns * thumbW + (columns + 1) * gap,
height: rows * thumbH + (rows + 1) * gap,
channels: 3,
background: "#e9e2dc",
},
})
.composite(
thumbs.map((input, i) => ({
input,
left: gap + (i % columns) * (thumbW + gap),
top: gap + Math.floor(i / columns) * (thumbH + gap),
})),
)
.png()
.toFile(out)
}
const encoder = (out: string) => {
const ffmpeg = spawn(
"ffmpeg",
[
"-y",
"-loglevel",
"error",
"-f",
"image2pipe",
"-framerate",
String(FPS),
"-i",
"-",
"-c:v",
"libx264",
"-preset",
"slow",
"-crf",
"16",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
out,
],
{ stdio: ["pipe", "inherit", "inherit"] },
)
const done = new Promise<void>((finish, reject) => {
ffmpeg.once("error", reject)
ffmpeg.once("close", (code) =>
code === 0 ? finish() : reject(new Error(`ffmpeg exited with ${code}`)),
)
})
// A failed encoder rejects `done`; the broken pipe it leaves needs no second report.
done.catch(() => {})
ffmpeg.stdin.on("error", () => {})
return {
write: async (frame: Buffer) => {
if (!ffmpeg.stdin.write(frame)) await Promise.race([once(ffmpeg.stdin, "drain"), done])
},
end: async () => {
ffmpeg.stdin.end()
await done
},
}
}
console.log(
`Timeline (${TOTAL / 1000} s): ${slots.map((slot) => `${slot.name} ${slot.start / 1000}–${slot.end / 1000}`).join(", ")}`,
)
for (const [ref, capture] of shots) {
console.log(
` ${ref.padEnd(28)} ${capture.width}×${capture.height} captured ${localTime(capture.takenAt)}`,
)
}
if (videoPath && spawnSync("ffmpeg", ["-version"]).error) {
throw new Error("ffmpeg is missing (brew install ffmpeg)")
}
const workDir = await mkdtemp(join(tmpdir(), "folo-release-video-"))
const browser = await chromium.launch()
try {
const pg = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 })
// A file:// document can load file:// captures, which setContent() cannot.
const file = join(workDir, "index.html")
await writeFile(file, pageHtml)
await pg.goto(pathToFileURL(file).href, { waitUntil: "networkidle" })
await pg.evaluate(async () => {
await document.fonts.ready
await Promise.all([...document.images].map((img) => img.decode().catch(() => undefined)))
// Headlines shrink until each of their lines fits on one line of the copy column.
for (const headline of document.querySelectorAll<HTMLElement>(".copy .headline")) {
// No named helpers in here: tsx wraps them in a __name() call the page lacks.
let size = Number.parseFloat(headline.style.fontSize)
while (
size > 64 &&
[...headline.querySelectorAll(".t")].some((line) => line.getClientRects().length > 1)
) {
size -= 2
headline.style.fontSize = `${size}px`
}
}
for (const animation of document.getAnimations()) animation.pause()
})
const problems = await checkAll(pg)
for (const problem of problems) console.warn(`PROBLEM ${problem}`)
if (stillsDir) {
await mkdir(stillsDir, { recursive: true })
const paths: string[] = []
for (const [i, slot] of slots.entries()) {
await seek(pg, stillAt(slot))
const path = join(stillsDir, `${String(i).padStart(2, "0")}-${slot.name}.png`)
await pg.screenshot({ path })
paths.push(path)
}
await contactSheet(paths, join(stillsDir, "contact-sheet.png"))
console.log(`Wrote ${paths.length} stills and contact-sheet.png to ${stillsDir}`)
}
if (videoPath && problems.length > 0) {
console.error("Not rendering the video until the problems above are fixed")
} else if (videoPath) {
await mkdir(join(resolve(videoPath), ".."), { recursive: true })
const video = encoder(videoPath)
const frames = Math.round((TOTAL / 1000) * FPS)
for (let i = 0; i < frames; i++) {
await seek(pg, (i * 1000) / FPS)
await video.write(await pg.screenshot({ type: "png" }))
if (i % 150 === 0) console.log(`frame ${i}/${frames}`)
}
await video.end()
console.log(`Wrote ${videoPath} (${frames} frames, ${TOTAL / 1000} s)`)
}
process.exitCode = problems.length > 0 ? 1 : 0
} finally {
await browser.close()
await rm(workDir, { recursive: true, force: true })
}