462 lines
23 KiB
JavaScript
462 lines
23 KiB
JavaScript
import { createRequire } from "node:module";
|
|
import { createServer } from "node:http";
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { execFileSync } from "node:child_process";
|
|
import { dirname, extname, join, normalize, resolve } from "node:path";
|
|
|
|
const usage = "usage: node story.mjs <story.html> <out.mp4> [--wide], or node story.mjs <story.html> --play to watch it live in a browser window";
|
|
const args = process.argv.slice(2);
|
|
const [input, out] = args.filter((arg) => !arg.startsWith("--"));
|
|
const wide = args.includes("--wide");
|
|
const play = args.includes("--play");
|
|
if (!input || (!out && !play)) {
|
|
console.error(usage);
|
|
process.exit(1);
|
|
}
|
|
|
|
async function browserKit() {
|
|
for (const name of ["playwright", "playwright-core"]) {
|
|
try {
|
|
return await import(name);
|
|
} catch {}
|
|
try {
|
|
return createRequire(join(process.cwd(), "package.json"))(name);
|
|
} catch {}
|
|
}
|
|
console.error("Playwright is not installed. In your project, run `npm install -D playwright` followed by `npx playwright install chromium`.");
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
|
} catch {
|
|
console.error("ffmpeg is not installed. Install it to render the video, for example `brew install ffmpeg`.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const file = resolve(input);
|
|
const root = dirname(file);
|
|
const html = readFileSync(file, "utf8");
|
|
const meta = (name) => new RegExp(`<meta\\s+name="${name}"\\s+content="([^"]*)"`, "i").exec(html)?.[1];
|
|
const steps = (meta("unslop-steps") ?? "")
|
|
.split(",")
|
|
.map((step) => step.trim())
|
|
.filter(Boolean);
|
|
if (steps.length === 0) {
|
|
console.error('story.html needs <meta name="unslop-steps" content="Spacing, Color, Type"> listing the steps in order');
|
|
process.exit(1);
|
|
}
|
|
const [frameX = 0, frameY = 0, frameWidth = 960] = (meta("unslop-frame") ?? "0 0 960").split(/\s+/).map(Number);
|
|
const kindOf = (text) => (/spac/i.test(text) ? "spacing" : /detail|radius|corner/i.test(text) ? "details" : null);
|
|
|
|
const FRAME = wide ? { width: 1920, height: 1080 } : { width: 1080, height: 1080 };
|
|
const PAGE_WIDTH = 1440;
|
|
const MARGIN = 72;
|
|
const VIEW = 0.75;
|
|
const scale = (FRAME.width - MARGIN) / frameWidth;
|
|
const offsetX = MARGIN / scale - frameX;
|
|
const offsetY = MARGIN / scale - frameY;
|
|
const padLeft = Math.max(0, offsetX);
|
|
const padTop = Math.max(0, offsetY);
|
|
const left = Math.min(0, offsetX) * scale;
|
|
const top = Math.min(0, offsetY) * scale;
|
|
const pageWidth = Math.max(PAGE_WIDTH + padLeft, Math.ceil((FRAME.width - left) / scale) + 2);
|
|
const padRight = pageWidth - PAGE_WIDTH - padLeft;
|
|
const pageHeight = Math.max(900 + padTop, Math.ceil((FRAME.height - top) / scale) + 2);
|
|
|
|
const stage = `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
html, body { margin: 0; width: ${FRAME.width}px; height: ${FRAME.height}px; overflow: hidden; background: #111; }
|
|
${play ? `body { transform: scale(${VIEW}); transform-origin: 0 0; }` : ""}
|
|
#story-layer { position: absolute; inset: 0; pointer-events: none; z-index: 10; }
|
|
#story-layer > * { position: absolute; }
|
|
#story-layer .s-fade { opacity: 0; transition: opacity 240ms ease; }
|
|
#story-layer.s-on .s-fade { opacity: 1; }
|
|
#story-layer .s-x { transform: scaleX(0); transition: transform 520ms cubic-bezier(0.65, 0, 0.35, 1), background-color 300ms ease; }
|
|
#story-layer .s-y { transform: scaleY(0); transition: transform 520ms cubic-bezier(0.65, 0, 0.35, 1), background-color 300ms ease; }
|
|
#story-layer.s-on .s-x { transform: scaleX(1); }
|
|
#story-layer.s-on .s-y { transform: scaleY(1); }
|
|
#story-layer .s-tag { font: 500 15px/30px ui-monospace, "Geist Mono", "SF Mono", Menlo, monospace; height: 30px; padding: 0 12px; border-radius: 999px; white-space: nowrap; color: #fff; background: #ff4d6a; font-variant-numeric: tabular-nums; transition: opacity 220ms ease 100ms, background-color 300ms ease, color 300ms ease; }
|
|
#story-layer.s-fixed .s-tag { background: #f5f5f5; color: #111; }
|
|
#story-layer .s-ink { background: #ff4d6a; }
|
|
#story-layer .s-hair { background: rgba(255,77,106,0.55); }
|
|
#story-layer .s-fill { background: rgba(255,77,106,0.1); }
|
|
#story-layer.s-fixed .s-ink { background: rgba(255,255,255,0.92); }
|
|
#story-layer.s-fixed .s-hair { background: rgba(255,255,255,0.24); }
|
|
#story-layer.s-fixed .s-fill { background: rgba(255,255,255,0.05); }
|
|
#story-layer .s-box { border: 1.5px solid #ff4d6a; background: rgba(255,77,106,0.07); transform: scale(1.03); transition: opacity 220ms ease, transform 260ms ease, border-color 300ms ease, background-color 300ms ease; }
|
|
#story-layer.s-on .s-box { transform: scale(1); }
|
|
#story-layer.s-fixed .s-box { border-color: rgba(255,255,255,0.92); background: rgba(255,255,255,0.04); }
|
|
#story-layer .s-corner { width: 8px; height: 8px; background: #fff; border: 1px solid #ff4d6a; }
|
|
.credit { position: absolute; right: 28px; bottom: 24px; z-index: 20; padding: 7px 12px; border-radius: 999px; font: 500 15px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0.01em; color: rgba(255,255,255,0.86); background: rgba(12,12,14,0.62); box-shadow: 0 0 0 1px rgba(255,255,255,0.1); backdrop-filter: blur(8px); }
|
|
iframe { position: absolute; left: ${left}px; top: ${top}px; border: 0; width: ${pageWidth}px; height: ${pageHeight}px; transform-origin: 0 0; transform: scale(${scale}); background: transparent; }
|
|
</style></head><body>
|
|
<iframe id="page" src="/__story/${encodeURIComponent(file.slice(root.length + 1))}"></iframe>
|
|
<div id="story-layer"></div>
|
|
<div class="credit">unslop by @heydamir</div>
|
|
<script>
|
|
window.__stage = { conf: { scale: ${scale}, x: ${left}, y: ${top} } };
|
|
</script>
|
|
</body></html>`;
|
|
|
|
const runtime = `
|
|
(() => {
|
|
const red = "#ff4d6a";
|
|
const spring = "cubic-bezier(0.3, 1.12, 0.45, 1)";
|
|
const ease = "cubic-bezier(0.45, 0, 0.2, 1)";
|
|
const layout = ["padding", "margin", "gap", "row-gap", "column-gap", "width", "height", "min-width", "min-height", "max-width", "max-height", "top", "right", "bottom", "left", "inset", "font-size", "line-height", "letter-spacing", "font-weight", "transform", "translate", "scale", "rotate", "border-width", "outline-width", "flex-basis", "grid-template-columns", "grid-template-rows"];
|
|
const tint = ["color", "background-color", "border-color", "outline-color", "fill", "stroke", "stroke-dashoffset", "stroke-width", "box-shadow", "text-shadow", "border-radius", "opacity", "filter", "backdrop-filter", "clip-path", "text-decoration-color", "-webkit-text-fill-color"];
|
|
const transition = [...layout.map((p) => p + " 700ms " + spring + " var(--wave, 0ms)"), ...tint.map((p) => p + " 900ms " + ease + " var(--wave, 0ms)")].join(", ");
|
|
const rise = "cubic-bezier(0.17, 0.84, 0.44, 1)";
|
|
const steps = ${JSON.stringify(steps)};
|
|
let css = \`
|
|
:root { interpolate-size: allow-keywords; }
|
|
html.story-live *, html.story-live *::before, html.story-live *::after { transition: \${transition} !important; }
|
|
.swap { display: inline-grid; justify-items: start; align-items: start; vertical-align: top; overflow: visible; }
|
|
div.swap, p.swap, h1.swap, h2.swap, h3.swap, li.swap { display: grid; }
|
|
.swap > .from, .swap > .to { grid-area: 1 / 1; }
|
|
.swap > .to { opacity: 0; pointer-events: none; }
|
|
.swap > .to .ch { display: inline-block; opacity: 0; transform: translateY(40%); filter: blur(8px); }
|
|
html.story-live .swap > .to .ch { transition: opacity 700ms \${rise} calc(var(--wave, 0ms) + 140ms + var(--ci) * 16ms), transform 700ms \${rise} calc(var(--wave, 0ms) + 140ms + var(--ci) * 16ms), filter 700ms \${rise} calc(var(--wave, 0ms) + 140ms + var(--ci) * 16ms) !important; }
|
|
[data-in] { opacity: 0; transform: translateY(10px) scale(0.98); }
|
|
\`;
|
|
steps.forEach((_, index) => {
|
|
const k = index + 1;
|
|
css += \`
|
|
html.s\${k} .swap[data-at="\${k}"] > .from { opacity: 0; filter: blur(10px); transform: translateY(-14px); }
|
|
html.s\${k} .swap[data-at="\${k}"] > .to { opacity: 1; }
|
|
html.s\${k} .swap[data-at="\${k}"] > .to .ch { opacity: 1; transform: none; filter: none; }
|
|
html.s\${k} [data-in="\${k}"] { opacity: 1; transform: none; }
|
|
html.s\${k} [data-out="\${k}"] { opacity: 0; max-height: 0 !important; max-width: 0 !important; margin-top: 0 !important; margin-bottom: 0 !important; padding: 0 !important; border-width: 0 !important; }
|
|
\`;
|
|
});
|
|
|
|
function letters(node) {
|
|
let index = 0;
|
|
const walk = (el) => {
|
|
[...el.childNodes].forEach((child) => {
|
|
if (child.nodeType === 3) {
|
|
const frag = document.createDocumentFragment();
|
|
child.textContent.split(/(\\s+)/).forEach((part) => {
|
|
if (!part) return;
|
|
if (/^\\s+$/.test(part)) return frag.appendChild(document.createTextNode(part));
|
|
const word = document.createElement("span");
|
|
word.style.display = "inline-block";
|
|
word.style.whiteSpace = "nowrap";
|
|
[...part].forEach((char) => {
|
|
const span = document.createElement("span");
|
|
span.className = "ch";
|
|
span.style.setProperty("--ci", index++);
|
|
span.textContent = char;
|
|
word.appendChild(span);
|
|
});
|
|
frag.appendChild(word);
|
|
});
|
|
child.replaceWith(frag);
|
|
} else if (child.nodeType === 1 && child.tagName.toLowerCase() !== "svg") {
|
|
walk(child);
|
|
}
|
|
});
|
|
};
|
|
walk(node);
|
|
}
|
|
|
|
const conf = window.parent.__stage.conf;
|
|
function layer() {
|
|
return window.parent.document.getElementById("story-layer");
|
|
}
|
|
|
|
const box = (el) => el.getBoundingClientRect();
|
|
const sx = (x) => conf.x + x * conf.scale;
|
|
const sy = (y) => conf.y + y * conf.scale;
|
|
function put(className, style, html) {
|
|
const node = window.parent.document.createElement("div");
|
|
node.className = className;
|
|
Object.assign(node.style, style);
|
|
if (html) node.innerHTML = html;
|
|
layer().appendChild(node);
|
|
return node;
|
|
}
|
|
const rect = (left, top, width, height) => ({ left: sx(left) + "px", top: sy(top) + "px", width: width * conf.scale + "px", height: height * conf.scale + "px" });
|
|
|
|
function audit() {
|
|
document.querySelectorAll("[data-slop]").forEach((el, index) => {
|
|
const b = box(el);
|
|
if (b.width < 4 || b.bottom < 0 || b.top > innerHeight) return;
|
|
const pad = 8 / conf.scale;
|
|
const wrap = put("s-fade", { ...rect(b.left - pad, b.top - pad, b.width + pad * 2, b.height + pad * 2), transitionDelay: index * 70 + "ms" });
|
|
wrap.innerHTML = '<div class="s-box" style="position:absolute;inset:0"></div>' +
|
|
[["left", "top"], ["right", "top"], ["left", "bottom"], ["right", "bottom"]].map(([x, y]) => '<span class="s-corner" style="position:absolute;' + x + ':-4px;' + y + ':-4px"></span>').join("") +
|
|
'<span class="s-tag" style="position:absolute;left:0;top:-40px">' + el.dataset.slop + "</span>";
|
|
});
|
|
}
|
|
|
|
function spacing() {
|
|
const groups = new Map();
|
|
document.querySelectorAll("[data-gap]").forEach((el) => {
|
|
const key = el.dataset.gap || "main";
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(el);
|
|
});
|
|
const stageWidth = window.parent.innerWidth;
|
|
const several = groups.size > 1;
|
|
const pairs = [];
|
|
groups.forEach((list) => {
|
|
const items = list.filter((el) => box(el).height > 0 && box(el).top < innerHeight).sort((a, b) => box(a).top - box(b).top);
|
|
for (let i = 1; i < items.length; i++) {
|
|
if (box(items[i]).top - box(items[i - 1]).bottom <= 2) continue;
|
|
pairs.push({ group: items, above: items[i - 1], below: items[i], nodes: {
|
|
fill: put("s-fill s-x", {}), top: put("s-hair s-x", {}), bottom: put("s-hair s-x", {}),
|
|
ruler: put("s-ink s-y", {}), capTop: put("s-ink s-x", {}), capBottom: put("s-ink s-x", {}),
|
|
tag: put("s-tag s-fade", {}),
|
|
} });
|
|
}
|
|
});
|
|
return () => {
|
|
for (const { group, above, below, nodes } of pairs) {
|
|
const a = box(above);
|
|
const b = box(below);
|
|
const left = sx(Math.min(...group.map((el) => box(el).left)));
|
|
const ruler = Math.max(14, left - 20);
|
|
const top = sy(a.bottom);
|
|
const size = Math.max(0, (b.top - a.bottom) * conf.scale);
|
|
const value = Math.round(b.top - a.bottom);
|
|
const right = several ? sx(Math.max(...group.map((el) => box(el).right))) + 24 : stageWidth;
|
|
const start = several ? ruler - 8 : 0;
|
|
const band = { left: start + "px", width: right - start + "px", transformOrigin: ruler - start + "px 50%" };
|
|
Object.assign(nodes.fill.style, band, { top: top + "px", height: size + "px" });
|
|
Object.assign(nodes.top.style, band, { top: top + "px", height: "1px" });
|
|
Object.assign(nodes.bottom.style, band, { top: top + size - 1 + "px", height: "1px" });
|
|
Object.assign(nodes.ruler.style, { left: ruler + "px", top: top + "px", width: "1.5px", height: size + "px", transformOrigin: "50% 0" });
|
|
Object.assign(nodes.capTop.style, { left: ruler - 4 + "px", top: top + "px", width: "9px", height: "1.5px" });
|
|
Object.assign(nodes.capBottom.style, { left: ruler - 4 + "px", top: top + size - 1.5 + "px", width: "9px", height: "1.5px" });
|
|
const room = ruler - 10 > 64;
|
|
Object.assign(nodes.tag.style, { left: (room ? ruler - 10 : ruler + 12) + "px", top: top + size / 2 - 15 + "px", translate: room ? "-100% 0" : "0 0" });
|
|
const text = value + "px";
|
|
if (nodes.tag.textContent !== text) nodes.tag.textContent = text;
|
|
const hidden = value < 4 ? "0" : "";
|
|
Object.values(nodes).forEach((node) => {
|
|
if (node.style.visibility !== (hidden ? "hidden" : "")) node.style.visibility = hidden ? "hidden" : "";
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
function details() {
|
|
const items = [...document.querySelectorAll("[data-radius]")].filter((el) => box(el).bottom > 0 && box(el).top < innerHeight).map((el) => {
|
|
const wrap = put("s-fade", {}, '<div class="s-box" style="position:absolute;inset:0"></div>');
|
|
return { el, wrap, outline: wrap.firstChild, tag: put("s-tag s-fade", {}) };
|
|
});
|
|
return () => {
|
|
for (const { el, wrap, outline, tag } of items) {
|
|
const b = box(el);
|
|
const radius = Math.round(Math.min(parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0, b.width / 2, b.height / 2));
|
|
const pad = 5 / conf.scale;
|
|
Object.assign(wrap.style, rect(b.left - pad, b.top - pad, b.width + pad * 2, b.height + pad * 2));
|
|
outline.style.borderRadius = radius * conf.scale + 5 + "px";
|
|
Object.assign(tag.style, { left: sx(b.left) + "px", top: sy(b.bottom) + 12 + "px" });
|
|
const text = "r " + radius;
|
|
if (tag.textContent !== text) tag.textContent = text;
|
|
}
|
|
};
|
|
}
|
|
|
|
const kinds = { audit, spacing, details };
|
|
const managed = new WeakMap();
|
|
let current = null;
|
|
let stepping = false;
|
|
|
|
window.__story = {
|
|
install() {
|
|
const style = document.createElement("style");
|
|
style.id = "story-style";
|
|
style.textContent = css;
|
|
document.head.appendChild(style);
|
|
document.querySelectorAll(".swap > .to").forEach(letters);
|
|
document.querySelectorAll("[data-out]").forEach((el) => {
|
|
const b = box(el);
|
|
if (getComputedStyle(el).display === "inline") el.style.display = "inline-block";
|
|
el.style.overflow = "hidden";
|
|
el.style.maxHeight = Math.ceil(b.height) + "px";
|
|
el.style.maxWidth = Math.ceil(b.width) + "px";
|
|
});
|
|
document.querySelectorAll("body *").forEach((el) => {
|
|
const b = box(el);
|
|
const wave = Math.round(Math.min(420, Math.max(0, b.top) / innerHeight * 320 + Math.max(0, b.left) / innerWidth * 100));
|
|
el.style.setProperty("--wave", wave + "ms");
|
|
});
|
|
document.querySelectorAll("*").forEach((el) => {
|
|
if (getComputedStyle(el).animationName !== "none") el.style.animationPlayState = "paused";
|
|
});
|
|
document.documentElement.offsetHeight;
|
|
document.documentElement.classList.add("story-live");
|
|
const follow = () => {
|
|
if (current && !stepping) current();
|
|
requestAnimationFrame(follow);
|
|
};
|
|
requestAnimationFrame(follow);
|
|
},
|
|
reset() {
|
|
const root = document.documentElement;
|
|
root.classList.remove("story-live", ...steps.map((_, index) => "s" + (index + 1)));
|
|
layer().className = "";
|
|
layer().innerHTML = "";
|
|
current = null;
|
|
root.offsetHeight;
|
|
root.classList.add("story-live");
|
|
},
|
|
tick(ms) {
|
|
stepping = true;
|
|
for (const animation of [...document.getAnimations(), ...window.parent.document.getAnimations()]) {
|
|
if (!managed.has(animation)) {
|
|
if (animation.playState !== "running") continue;
|
|
managed.set(animation, ms);
|
|
animation.pause();
|
|
}
|
|
const time = ms - managed.get(animation);
|
|
const end = animation.effect ? animation.effect.getComputedTiming().endTime : Infinity;
|
|
if (Number.isFinite(end) && time >= end) animation.finish();
|
|
else animation.currentTime = time;
|
|
}
|
|
if (current) current();
|
|
},
|
|
step(k) {
|
|
document.documentElement.classList.add("s" + k);
|
|
},
|
|
show(kind) {
|
|
const node = layer();
|
|
node.className = "";
|
|
node.innerHTML = "";
|
|
const update = (kinds[kind] || (() => {}))();
|
|
current = typeof update === "function" ? update : null;
|
|
if (current) current();
|
|
node.offsetHeight;
|
|
node.classList.add("s-on");
|
|
},
|
|
settle() {
|
|
layer().classList.add("s-on", "s-fixed");
|
|
},
|
|
clear() {
|
|
layer().classList.remove("s-on");
|
|
},
|
|
};
|
|
})();
|
|
`;
|
|
|
|
const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".woff": "font/woff", ".mp4": "video/mp4" };
|
|
const server = createServer((request, response) => {
|
|
const path = decodeURIComponent(new URL(request.url, "http://local").pathname);
|
|
if (path === "/__stage.html") {
|
|
response.writeHead(200, { "content-type": "text/html" });
|
|
return response.end(stage);
|
|
}
|
|
const target = normalize(join(root, path.replace(/^\/__story\//, "/")));
|
|
if (!target.startsWith(root) || !existsSync(target)) {
|
|
response.writeHead(404);
|
|
return response.end();
|
|
}
|
|
response.writeHead(200, { "content-type": types[extname(target).toLowerCase()] ?? "application/octet-stream" });
|
|
response.end(readFileSync(target));
|
|
});
|
|
await new Promise((done) => server.listen(0, "127.0.0.1", done));
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
|
|
const FPS = 60;
|
|
const LENGTH = 10;
|
|
const frames = join(root, ".story-frames");
|
|
if (!play) {
|
|
rmSync(frames, { recursive: true, force: true });
|
|
mkdirSync(frames, { recursive: true });
|
|
}
|
|
|
|
const chrome = [process.env.CHROME_PATH, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium"].find((path) => path && existsSync(path));
|
|
const { chromium } = await browserKit();
|
|
const browser = await chromium.launch({ ...(chrome ? { executablePath: chrome } : {}), headless: !play });
|
|
const sleep = (ms) => new Promise((done) => setTimeout(done, Math.max(0, ms)));
|
|
let total = 0;
|
|
try {
|
|
const page = await browser.newPage(play ? { viewport: { width: Math.round(FRAME.width * VIEW), height: Math.round(FRAME.height * VIEW) } } : { viewport: FRAME, deviceScaleFactor: 2 });
|
|
await page.goto(`${base}/__stage.html`, { waitUntil: "networkidle" });
|
|
const frame = page.frames().find((item) => item !== page.mainFrame());
|
|
await frame.waitForLoadState("networkidle");
|
|
await frame.evaluate(async () => {
|
|
const families = new Set();
|
|
for (const sheet of document.styleSheets) {
|
|
let rules = [];
|
|
try {
|
|
rules = [...sheet.cssRules];
|
|
} catch {}
|
|
const walk = (list) => list.forEach((rule) => {
|
|
if (rule.cssRules) walk([...rule.cssRules]);
|
|
const value = rule.style && rule.style.fontFamily;
|
|
if (value) value.split(",").forEach((name) => families.add(name.trim().replace(/^["']|["']$/g, "")));
|
|
});
|
|
walk(rules);
|
|
}
|
|
await Promise.all([...families].flatMap((family) => [400, 500, 600, 700, 800].map((weight) => document.fonts.load(`${weight} 16px "${family}"`).catch(() => null))));
|
|
await document.fonts.ready;
|
|
});
|
|
await frame.addStyleTag({ content: `html { padding: ${padTop}px ${padRight}px 0 ${padLeft}px !important; }` });
|
|
await frame.addScriptTag({ content: runtime });
|
|
await frame.evaluate(() => window.__story.install());
|
|
await page.waitForTimeout(300);
|
|
|
|
const slop = await frame.evaluate(() => document.querySelectorAll("[data-slop]").length);
|
|
const call = (name, value) => () => frame.evaluate(([name, value]) => window.__story[name](value), [name, value]);
|
|
const plan = [];
|
|
const at = (time, run) => plan.push({ time, run });
|
|
let cursor = 0.5;
|
|
if (slop) {
|
|
at(cursor, call("show", "audit"));
|
|
at(cursor + 0.9, call("clear"));
|
|
cursor += 1.1;
|
|
}
|
|
const blocks = steps.map((name) => (kindOf(name) ? 1.75 : 1.25));
|
|
const fit = Math.min(1, (LENGTH - cursor - 1.6) / blocks.reduce((sum, block) => sum + block, 0));
|
|
steps.forEach((name, index) => {
|
|
const kind = kindOf(name);
|
|
if (kind) {
|
|
at(cursor, call("show", kind));
|
|
at(cursor + 0.35 * fit, call("step", index + 1));
|
|
at(cursor + 1.35 * fit, call("settle"));
|
|
at(cursor + 1.7 * fit, call("clear"));
|
|
} else {
|
|
at(cursor, call("step", index + 1));
|
|
}
|
|
cursor += blocks[index] * fit;
|
|
});
|
|
total = cursor + 1.6;
|
|
plan.sort((a, b) => a.time - b.time);
|
|
|
|
if (play) {
|
|
console.log("playing in the browser window, close it to stop");
|
|
while (browser.isConnected()) {
|
|
await frame.evaluate(() => window.__story.reset());
|
|
await sleep(400);
|
|
const begin = Date.now();
|
|
for (const { time, run } of plan) {
|
|
await sleep(begin + time * 1000 - Date.now());
|
|
await run();
|
|
}
|
|
await sleep(begin + total * 1000 - Date.now());
|
|
}
|
|
} else {
|
|
let next = 0;
|
|
for (let index = 0; index <= Math.round(total * FPS); index++) {
|
|
const now = index / FPS;
|
|
while (next < plan.length && plan[next].time <= now + 1e-6) await plan[next++].run();
|
|
await frame.evaluate((ms) => window.__story.tick(ms), now * 1000);
|
|
await page.screenshot({ path: join(frames, `f-${String(index).padStart(5, "0")}.jpg`), type: "jpeg", quality: 92 });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!play) throw error;
|
|
} finally {
|
|
await browser.close().catch(() => {});
|
|
server.close();
|
|
}
|
|
if (play) process.exit(0);
|
|
|
|
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-framerate", String(FPS), "-i", join(frames, "f-%05d.jpg"), "-vf", `scale=${FRAME.width * 2}:${FRAME.height * 2}:flags=lanczos`, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "slow", "-movflags", "+faststart", resolve(out)]);
|
|
rmSync(frames, { recursive: true, force: true });
|
|
console.log(`saved ${resolve(out)}, ${steps.length} steps, ${total.toFixed(1)}s`);
|