From 446960881b15c057eef52387be3fe774eb706bfd Mon Sep 17 00:00:00 2001 From: umiyosh Date: Sun, 25 Oct 2026 23:03:58 +0900 Subject: [PATCH 1/4] fix(cli): time-limit film page calls and ffmpeg so a stall fails instead of hanging --- skills/motion-render/scripts/audio.ts | 70 ++++++++++++++----------- skills/motion-render/scripts/cli.ts | 23 +++++---- skills/motion-render/scripts/limit.ts | 20 ++++++++ skills/motion-render/scripts/render.ts | 71 ++++++++++++++++++++------ tests/audio.test.ts | 30 +++++++++-- tests/html-film.test.ts | 22 ++++++++ tests/limit.test.ts | 17 ++++++ 7 files changed, 191 insertions(+), 62 deletions(-) create mode 100644 skills/motion-render/scripts/limit.ts create mode 100644 tests/limit.test.ts diff --git a/skills/motion-render/scripts/audio.ts b/skills/motion-render/scripts/audio.ts index 22198f8..b858313 100644 --- a/skills/motion-render/scripts/audio.ts +++ b/skills/motion-render/scripts/audio.ts @@ -1,14 +1,18 @@ // ナレーション(Gemini TTS)と BGM(Lyria)を作り、ffmpeg で 1 本の音声に合成する。 import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { spawn, spawnSync } from "node:child_process"; +import { basename, join, dirname } from "node:path"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createRequire } from "node:module"; import { errorMessage, field, readJson } from "./json.ts"; +import { limit } from "./limit.ts"; import type { Script } from "./work.ts"; const API = "https://generativelanguage.googleapis.com/v1beta/models"; export const TIMEOUT_MS = 90_000; // a TTS request once hung for 15 minutes without this const RETRIES = 3; +// An audio job (mixing a few minutes of sound, measuring a level) takes ffmpeg seconds. One still +// running after this is stuck, not slow. +export const FFMPEG_MS = 300_000; interface Audio { mimeType: string; @@ -60,40 +64,46 @@ export function findFfmpeg(): string { throw new Error("ffmpeg not found: install ffmpeg or set FFMPEG_PATH"); } -export function runFfmpeg(args: string[]): Promise { +// Resolves with ffmpeg's exit code once it has exited. +export function exited(ff: ChildProcess): Promise { return new Promise((ok, fail) => { - const ff = spawn(findFfmpeg(), ["-y", "-loglevel", "error", ...args], { - stdio: ["ignore", "inherit", "inherit"], - }); ff.on("error", fail); - ff.on("close", (code) => - code === 0 ? ok() : fail(new Error(`ffmpeg exited with code ${code}`)) - ); + ff.on("close", ok); }); } -// Average and peak level of an audio file in dBFS (ffmpeg's volumedetect). Silence reads as -91 dB or lower. -export function volumeStats(file: string): Promise<{ mean: number; max: number }> { - return new Promise((ok, fail) => { - const args = ["-hide_banner", "-nostats", "-i", file, "-af", "volumedetect", "-f", "null", "-"]; - const ff = spawn(findFfmpeg(), args, { stdio: ["ignore", "ignore", "pipe"] }); - let log = ""; - ff.stderr?.on("data", (d: Buffer) => (log += d.toString())); - ff.on("error", fail); - ff.on("close", (code) => { - const level = (name: string): number => { - const v = new RegExp(`${name}: (-?[\\d.]+|-inf) dB`).exec(log)?.[1]; - return v === "-inf" ? -120 : Number(v ?? NaN); - }; - const mean = level("mean_volume"), - max = level("max_volume"); - if (code !== 0 || !Number.isFinite(mean) || !Number.isFinite(max)) - return fail( - new Error(`could not measure the level of ${file} (ffmpeg exited with ${code})`) - ); - ok({ mean, max }); - }); +// Waits for ffmpeg to exit, killing it if it runs past ms. +function finish(ff: ChildProcess, what: string, ms: number): Promise { + return limit(exited(ff), ms, what, () => ff.kill("SIGKILL")); +} + +export async function runFfmpeg(args: string[], ms = FFMPEG_MS): Promise { + const ff = spawn(findFfmpeg(), ["-y", "-loglevel", "error", ...args], { + stdio: ["ignore", "inherit", "inherit"], }); + const code = await finish(ff, `ffmpeg writing ${basename(args.at(-1) ?? "")}`, ms); + if (code !== 0) throw new Error(`ffmpeg exited with code ${code}`); +} + +// Average and peak level of an audio file in dBFS (ffmpeg's volumedetect). Silence reads as -91 dB or lower. +export async function volumeStats( + file: string, + ms = FFMPEG_MS +): Promise<{ mean: number; max: number }> { + const args = ["-hide_banner", "-nostats", "-i", file, "-af", "volumedetect", "-f", "null", "-"]; + const ff = spawn(findFfmpeg(), args, { stdio: ["ignore", "ignore", "pipe"] }); + let log = ""; + ff.stderr?.on("data", (d: Buffer) => (log += d.toString())); + const code = await finish(ff, `ffmpeg measuring ${basename(file)}`, ms); + const level = (name: string): number => { + const v = new RegExp(`${name}: (-?[\\d.]+|-inf) dB`).exec(log)?.[1]; + return v === "-inf" ? -120 : Number(v ?? NaN); + }; + const mean = level("mean_volume"), + max = level("max_volume"); + if (code !== 0 || !Number.isFinite(mean) || !Number.isFinite(max)) + throw new Error(`could not measure the level of ${file} (ffmpeg exited with ${code})`); + return { mean, max }; } // Runs `attempt` up to RETRIES times, logging each failure under `label`, and rethrows the last error. diff --git a/skills/motion-render/scripts/cli.ts b/skills/motion-render/scripts/cli.ts index 01534f4..b588c9f 100755 --- a/skills/motion-render/scripts/cli.ts +++ b/skills/motion-render/scripts/cli.ts @@ -9,7 +9,8 @@ import { fileURLToPath } from "node:url"; import { openFilm, checkFilmDir, isHtmlFilm, type Film } from "./render.ts"; import { checkLayout, formatIssue } from "./layout.ts"; import { writeKit, writeKitTiming } from "./kit.ts"; -import { narrate, bgm, bgmFile, mix, findFfmpeg, encodeArgs } from "./audio.ts"; +import { narrate, bgm, bgmFile, mix, findFfmpeg, encodeArgs, exited, FFMPEG_MS } from "./audio.ts"; +import { limit } from "./limit.ts"; import { loadScript, writeTiming, loadTiming, placeOnFilm, placeSfx } from "./work.ts"; import { checkSfx, makeSfx, narrationLevel, previewSfx, sfxTrack } from "./sfx.ts"; import { prepare } from "./store.ts"; @@ -124,27 +125,29 @@ async function runSfx(work: string): Promise { console.log(`made ${made} new, listen to all of them: ${preview.file}`); } -// Pipes rendered frames straight into ffmpeg (no temp files) and muxes the mixed audio. +// ffmpeg takes a frame in milliseconds. One that has not taken it after this is stuck. +const FRAME_MS = 60_000; + +// Pipes rendered frames straight into ffmpeg (no temp files) and muxes the mixed audio. The whole +// encode runs as long as capture does, so the limits are per frame and for the final mux. async function encode(film: Film, fps: number, audio: string, out: string): Promise { const count = Math.round(film.meta.duration * fps); const args = encodeArgs(fps, audio, out); const ff = spawn(findFfmpeg(), args, { stdio: ["pipe", "inherit", "inherit"] }); - const finished = new Promise((ok, fail) => { - ff.on("error", fail); - ff.on("close", (code) => - code === 0 ? ok() : fail(new Error(`ffmpeg exited with code ${code}`)) - ); - }); + const kill = () => ff.kill("SIGKILL"); + const exit = exited(ff); ff.stdin.on("error", () => {}); // EPIPE when ffmpeg dies early; its exit code reports the cause. try { for (let i = 0; i < count; i++) { - if (!ff.stdin.write(await film.frame(i / fps))) await once(ff.stdin, "drain"); + if (!ff.stdin.write(await film.frame(i / fps))) + await limit(once(ff.stdin, "drain"), FRAME_MS, `ffmpeg taking frame ${i}`, kill); if (i % Math.max(1, Math.round(count / 10)) === 0) console.error(`frames: ${i}/${count}`); } } finally { ff.stdin.end(); } - await finished; + const code = await limit(exit, FFMPEG_MS, "ffmpeg finishing the MP4", kill); + if (code !== 0) throw new Error(`ffmpeg exited with code ${code}`); return count; } diff --git a/skills/motion-render/scripts/limit.ts b/skills/motion-render/scripts/limit.ts new file mode 100644 index 0000000..fcac69a --- /dev/null +++ b/skills/motion-render/scripts/limit.ts @@ -0,0 +1,20 @@ +// Time limits for the steps that can stall without an error: calls into the film page and ffmpeg. +// A stalled step then fails with its name instead of leaving the command waiting forever. + +// Fails with " did not finish in s" if p has not settled within ms. `onTimeout` runs first, +// to stop what is stuck (kill ffmpeg, for example). +export function limit( + p: Promise, + ms: number, + what: string, + onTimeout?: () => void +): Promise { + let timer: NodeJS.Timeout | undefined; + const late = new Promise((_, fail) => { + timer = setTimeout(() => { + onTimeout?.(); + fail(new Error(`${what} did not finish in ${ms / 1000}s`)); + }, ms); + }); + return Promise.race([p, late]).finally(() => clearTimeout(timer)); +} diff --git a/skills/motion-render/scripts/render.ts b/skills/motion-render/scripts/render.ts index d6d6623..d84f6e6 100644 --- a/skills/motion-render/scripts/render.ts +++ b/skills/motion-render/scripts/render.ts @@ -8,10 +8,20 @@ import { spawnSync } from "node:child_process"; import { chromium, type Browser, type LaunchOptions, type Page } from "playwright-core"; import { errorMessage, field, isRecord, readJson } from "./json.ts"; import { parseBoxes, type LayoutBox } from "./layout.ts"; +import { limit } from "./limit.ts"; const require = createRequire(import.meta.url); const PLAYWRIGHT_CLI = join(dirname(require.resolve("playwright-core")), "cli.js"); const SNAPSHOT = fileURLToPath(new URL("snapshot.js", import.meta.url)); +// A call into the film page takes well under a second. The page's code is not ours and can loop +// forever, and Playwright's evaluate never gives up on its own, so each call gets this limit. +const PAGE_MS = 60_000; + +// Runs one call into the film page under the time limit, named for the error. +type Call = (p: Promise, what: string) => Promise; + +// Seconds as the error shows them: 0.3, not 0.30000000000000004. +const sec = (t: number) => String(+t.toFixed(3)); const KIND: Partial> = { ".css": "css", @@ -201,10 +211,13 @@ async function openPage(browser: Browser): Promise { return page; } -async function motionFilm(page: Page, dir: string): Promise> { +async function motionFilm(page: Page, dir: string, call: Call): Promise> { await page.goto(pathToFileURL(resolve(dir, "index.html")).href + "#tools"); await page.waitForFunction(() => typeof window.__animation === "object"); - const res = await page.evaluate((f) => window.__animation.load(f), readFilm(dir)); + const res = await call( + page.evaluate((f) => window.__animation.load(f), readFilm(dir)), + "__animation.load()" + ); const error = field(res, "error"); if (error) { const msg = field(error, "message"); @@ -215,13 +228,19 @@ async function motionFilm(page: Page, dir: string): Promise> return { meta: parseMeta(field(res, "meta")), async frame(t) { - const url = await page.evaluate((tt) => window.__animation.frame(tt, { scale: 1 }), t); + const url = await call( + page.evaluate((tt) => window.__animation.frame(tt, { scale: 1 }), t), + `__animation.frame(${sec(t)})` + ); if (typeof url !== "string" || !url.includes(",")) throw new Error(`the film player returned no frame at ${t}s`); return Buffer.from(url.slice(url.indexOf(",") + 1), "base64"); }, async warnings() { - const log = await page.evaluate(() => window.__animation.consoleLog()); + const log = await call( + page.evaluate(() => window.__animation.consoleLog()), + "__animation.consoleLog()" + ); if (!Array.isArray(log)) return []; return log .filter((l: unknown) => isRecord(l) && l.level !== "log") @@ -230,15 +249,20 @@ async function motionFilm(page: Page, dir: string): Promise> }; } -async function waitReady(page: Page): Promise { +async function waitReady(page: Page, call: Call): Promise { const deadline = Date.now() + 30_000; - while (!(await page.evaluate(() => !window.__film.ready || window.__film.ready() !== false))) { + const ready = () => + call( + page.evaluate(() => !window.__film.ready || window.__film.ready() !== false), + "__film.ready()" + ); + while (!(await ready())) { if (Date.now() > deadline) throw new Error("the film page did not become ready in 30s"); await new Promise((r) => setTimeout(r, 20)); } } -async function htmlFilm(page: Page, dir: string): Promise> { +async function htmlFilm(page: Page, dir: string, call: Call): Promise> { const log: string[] = []; page.on("console", (m) => { if (m.type() === "warning" || m.type() === "error") log.push(`${m.type()}: ${m.text()}`); @@ -248,39 +272,52 @@ async function htmlFilm(page: Page, dir: string): Promise> { await page.waitForFunction(() => typeof window.__film === "object"); let meta: unknown; try { - await waitReady(page); - meta = await page.evaluate(() => window.__film.load()); + await waitReady(page, call); + meta = await call( + page.evaluate(() => window.__film.load()), + "__film.load()" + ); } catch (e) { throw new Error("film failed to load: " + errorMessage(e)); } let injected = false; const seek = async (t: number) => { - await page.evaluate((tt) => window.__film.seek(tt), t); - await waitReady(page); + await call( + page.evaluate((tt) => window.__film.seek(tt), t), + `__film.seek(${sec(t)})` + ); + await waitReady(page, call); }; return { meta: parseMeta(meta), async frame(t) { await seek(t); - return page.screenshot({ type: "png" }); + return call(page.screenshot({ type: "png" }), `screenshot at ${sec(t)}s`); }, async inspect(t) { await seek(t); - if (!injected) await page.addScriptTag({ path: SNAPSHOT }); + if (!injected) await call(page.addScriptTag({ path: SNAPSHOT }), "snapshot.js"); injected = true; - return parseBoxes(await page.evaluate(() => window.__layoutSnapshot())); + return parseBoxes( + await call( + page.evaluate(() => window.__layoutSnapshot()), + `layout snapshot at ${sec(t)}s` + ) + ); }, warnings: () => Promise.resolve(log), }; } -// Opens the film and returns its meta and a way to grab frames as PNG. -export async function openFilm(dir: string): Promise { +// Opens the film and returns its meta and a way to grab frames as PNG. Each call into the page +// fails after pageMs. +export async function openFilm(dir: string, pageMs = PAGE_MS): Promise { checkFilmDir(dir); const browser = await launch(); + const call: Call = (p, what) => limit(p, pageMs, `the film page's ${what}`); try { const page = await openPage(browser); - const film = await (isHtmlFilm(dir) ? htmlFilm : motionFilm)(page, dir); + const film = await (isHtmlFilm(dir) ? htmlFilm : motionFilm)(page, dir, call); return { ...film, close: () => browser.close() }; } catch (e) { await browser.close(); diff --git a/tests/audio.test.ts b/tests/audio.test.ts index 3f93545..834c0f9 100644 --- a/tests/audio.test.ts +++ b/tests/audio.test.ts @@ -4,22 +4,30 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { SCRIPT, tempDir, wav, workDir } from "./helpers.ts"; -// ffmpeg is never run in unit tests: spawn records its arguments and exits with `exitCode`. -const ffmpeg = vi.hoisted(() => ({ calls: [] as { cmd: string; args: string[] }[], exitCode: 0 })); +// ffmpeg is never run in unit tests: spawn records its arguments and exits with `exitCode`, or with +// `hang` never exits until it is killed. +const ffmpeg = vi.hoisted(() => ({ + calls: [] as { cmd: string; args: string[] }[], + exitCode: 0, + hang: false, + killed: [] as string[], +})); vi.mock("node:child_process", async (importOriginal) => { const real = await importOriginal(); return { ...real, spawn: (cmd: string, args: string[]) => { ffmpeg.calls.push({ cmd, args }); - const child = new EventEmitter(); - setImmediate(() => child.emit("close", ffmpeg.exitCode)); + const child = Object.assign(new EventEmitter(), { + kill: (signal: string) => ffmpeg.killed.push(signal), + }); + if (!ffmpeg.hang) setImmediate(() => child.emit("close", ffmpeg.exitCode)); return child; }, }; }); -const { bgm, bgmFile, encodeArgs, findFfmpeg, mix, narrate, wavSeconds } = +const { bgm, bgmFile, encodeArgs, findFfmpeg, mix, narrate, runFfmpeg, wavSeconds } = await import("../skills/motion-render/scripts/audio.ts"); const { loadScript } = await import("../skills/motion-render/scripts/work.ts"); @@ -46,6 +54,8 @@ beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); ffmpeg.calls = []; ffmpeg.exitCode = 0; + ffmpeg.hang = false; + ffmpeg.killed = []; }); afterEach(() => { vi.unstubAllEnvs(); @@ -261,6 +271,16 @@ describe("mix", () => { }); }); +describe("runFfmpeg", () => { + it("kills an ffmpeg that does not finish in time and names what it was writing", async () => { + ffmpeg.hang = true; + await expect(runFfmpeg(["-i", "in.wav", "/w/mix.m4a"], 50)).rejects.toThrow( + "ffmpeg writing mix.m4a did not finish in 0.05s" + ); + expect(ffmpeg.killed).toEqual(["SIGKILL"]); + }); +}); + describe("encodeArgs", () => { it("reads PNG frames from stdin at the frame rate and muxes the mixed audio into a streamable MP4", () => { const args = encodeArgs(24, "/w/mix.m4a", "/out/film.mp4"); diff --git a/tests/html-film.test.ts b/tests/html-film.test.ts index 1b2773d..796db4c 100644 --- a/tests/html-film.test.ts +++ b/tests/html-film.test.ts @@ -222,3 +222,25 @@ describe("html film", () => { expect(readdirSync(join(work, "stills")).sort()).toEqual(["t_0.50.png", "t_1.50.png"]); }); }); + +describe("a stuck film page", () => { + it("fails a frame whose seek never returns, naming the call and its time", async () => { + const dir = htmlFilm(false); + // From 1 s on, the page's seek spins forever, as a film with a bug in its own code could. + const stuck = ``; + const html = readFileSync(join(dir, "index.html"), "utf8").replace("", stuck); + writeFileSync(join(dir, "index.html"), html); + const film = await openFilm(dir, 1000); + try { + expect((await film.frame(0.5)).subarray(1, 4).toString()).toBe("PNG"); + await expect(film.frame(1.5)).rejects.toThrow( + "the film page's __film.seek(1.5) did not finish in 1s" + ); + } finally { + await film.close(); + } + }); +}); diff --git a/tests/limit.test.ts b/tests/limit.test.ts new file mode 100644 index 0000000..5cef0e2 --- /dev/null +++ b/tests/limit.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from "vitest"; +import { limit } from "../skills/motion-render/scripts/limit.ts"; + +describe("limit", () => { + it("passes through a result or an error that comes in time", async () => { + await expect(limit(Promise.resolve(3), 1000, "x")).resolves.toBe(3); + await expect(limit(Promise.reject(new Error("boom")), 1000, "x")).rejects.toThrow("boom"); + }); + + it("stops what is stuck and fails with its name when time runs out", async () => { + const stop = vi.fn(); + await expect( + limit(new Promise(() => {}), 20, "the film page's __film.seek(1)", stop) + ).rejects.toThrow("the film page's __film.seek(1) did not finish in 0.02s"); + expect(stop).toHaveBeenCalledOnce(); + }); +}); From 80ecb94c5b86f1fd6e5fb2f3209a0ae6c05ff109 Mon Sep 17 00:00:00 2001 From: umiyosh Date: Sun, 25 Oct 2026 23:35:53 +0900 Subject: [PATCH 2/4] perf(build): capture frames and layout samples on several pages in parallel --- skills/motion-render/scripts/cli.ts | 16 ++++-- skills/motion-render/scripts/inorder.ts | 23 ++++++++ skills/motion-render/scripts/layout.ts | 20 ++++--- skills/motion-render/scripts/render.ts | 76 ++++++++++++++++++++++--- tests/html-film.test.ts | 22 ++++++- tests/inorder.test.ts | 34 +++++++++++ tests/render.test.ts | 30 +++++++++- 7 files changed, 197 insertions(+), 24 deletions(-) create mode 100644 skills/motion-render/scripts/inorder.ts create mode 100644 tests/inorder.test.ts diff --git a/skills/motion-render/scripts/cli.ts b/skills/motion-render/scripts/cli.ts index b588c9f..55d9e2d 100755 --- a/skills/motion-render/scripts/cli.ts +++ b/skills/motion-render/scripts/cli.ts @@ -6,7 +6,8 @@ import { spawn } from "node:child_process"; import { once } from "node:events"; import { parseArgs } from "node:util"; import { fileURLToPath } from "node:url"; -import { openFilm, checkFilmDir, isHtmlFilm, type Film } from "./render.ts"; +import { openFilm, checkFilmDir, isHtmlFilm, capturePages, type Film } from "./render.ts"; +import { inOrder } from "./inorder.ts"; import { checkLayout, formatIssue } from "./layout.ts"; import { writeKit, writeKitTiming } from "./kit.ts"; import { narrate, bgm, bgmFile, mix, findFfmpeg, encodeArgs, exited, FFMPEG_MS } from "./audio.ts"; @@ -69,7 +70,7 @@ async function stills(filmDir: string, outDir: string, list: string): Promise !Number.isFinite(t))) die(`times must be numbers: ${list}`); mkdirSync(outDir, { recursive: true }); - const film = await openFilm(filmDir); + const film = await openFilm(filmDir, { pages: capturePages() }); try { for (const t of times) writeFileSync(join(outDir, `t_${t.toFixed(2)}.png`), await film.frame(t)); @@ -84,7 +85,7 @@ async function stills(filmDir: string, outDir: string, list: string): Promise { if (!isHtmlFilm(filmDir)) die("check works on html films only (Motion films draw in their own player)"); - const film = await openFilm(filmDir); + const film = await openFilm(filmDir, { pages: capturePages() }); let n: number; try { n = await reportLayout(film); @@ -138,10 +139,12 @@ async function encode(film: Film, fps: number, audio: string, out: string): Prom const exit = exited(ff); ff.stdin.on("error", () => {}); // EPIPE when ffmpeg dies early; its exit code reports the cause. try { - for (let i = 0; i < count; i++) { - if (!ff.stdin.write(await film.frame(i / fps))) + let i = 0; + for await (const png of inOrder(count, film.pages, (k) => film.frame(k / fps))) { + if (!ff.stdin.write(png)) await limit(once(ff.stdin, "drain"), FRAME_MS, `ffmpeg taking frame ${i}`, kill); if (i % Math.max(1, Math.round(count / 10)) === 0) console.error(`frames: ${i}/${count}`); + i++; } } finally { ff.stdin.end(); @@ -166,7 +169,8 @@ async function build(work: string, out: string, fps: number): Promise { checkFilmDir(filmDir); if (isHtmlFilm(filmDir)) writeKitTiming(work); - const film = await openFilm(filmDir); + const film = await openFilm(filmDir, { pages: capturePages() }); + console.error(`capture: ${film.pages} pages`); try { if (await reportLayout(film)) throw new Error( diff --git a/skills/motion-render/scripts/inorder.ts b/skills/motion-render/scripts/inorder.ts new file mode 100644 index 0000000..240b8ff --- /dev/null +++ b/skills/motion-render/scripts/inorder.ts @@ -0,0 +1,23 @@ +// Yields get(0) … get(count - 1) in order while keeping up to `ahead` of them in flight, so the +// pages of a film draw frames in parallel and the frames still come out in film order. At most +// `ahead` results are held at once. +export async function* inOrder( + count: number, + ahead: number, + get: (i: number) => Promise +): AsyncGenerator { + const queue: Promise[] = []; + let next = 0; + for (let i = 0; i < count; i++) { + while (next < count && queue.length < Math.max(1, ahead)) { + const p = get(next++); + // Awaited in order below. This only keeps a later one that fails while an earlier one is + // still awaited from being reported as unhandled. + p.catch(() => {}); + queue.push(p); + } + const p = queue.shift(); + if (!p) return; + yield await p; + } +} diff --git a/skills/motion-render/scripts/layout.ts b/skills/motion-render/scripts/layout.ts index f7de3d2..713f475 100644 --- a/skills/motion-render/scripts/layout.ts +++ b/skills/motion-render/scripts/layout.ts @@ -5,6 +5,7 @@ // Only what stays still is reported, so a chip passing over a card on its way is not a mistake. import type { Film } from "./render.ts"; import { isRecord } from "./json.ts"; +import { inOrder } from "./inorder.ts"; // A thing that paints on screen at one moment (snapshot.js). Coordinates are CSS px in the frame. export interface LayoutBox { @@ -228,15 +229,18 @@ export function parseBoxes(raw: unknown): LayoutBox[] { return raw.map(parseBox); } -// Samples the whole film every `step` seconds. A finding needs two samples in a row, so at 0.1 s a -// pose held for 0.2 s is reported. Motion films draw inside their own player and cannot be -// inspected, so they get null. +// Samples the whole film every `step` seconds, on all of the film's pages at once, and judges the +// samples in film order. A finding needs two samples in a row, so at 0.1 s a pose held for 0.2 s is +// reported. Motion films draw inside their own player and cannot be inspected, so they get null. export async function checkLayout(film: Film, step = 0.1): Promise { - if (!film.inspect) return null; - const samples: LayoutSample[] = []; - for (let i = 0; i * step < film.meta.duration; i++) { + const inspect = film.inspect; + if (!inspect) return null; + const count = Math.ceil(film.meta.duration / step - 1e-9); + const sample = (i: number): Promise => { const t = Math.round(i * step * 1000) / 1000; - samples.push({ t, boxes: await film.inspect(t) }); - } + return inspect(t).then((boxes) => ({ t, boxes })); + }; + const samples: LayoutSample[] = []; + for await (const s of inOrder(count, film.pages, sample)) samples.push(s); return findLayoutIssues(samples); } diff --git a/skills/motion-render/scripts/render.ts b/skills/motion-render/scripts/render.ts index d84f6e6..a6f42ea 100644 --- a/skills/motion-render/scripts/render.ts +++ b/skills/motion-render/scripts/render.ts @@ -5,6 +5,7 @@ import { join, resolve, extname, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createRequire } from "node:module"; import { spawnSync } from "node:child_process"; +import { availableParallelism } from "node:os"; import { chromium, type Browser, type LaunchOptions, type Page } from "playwright-core"; import { errorMessage, field, isRecord, readJson } from "./json.ts"; import { parseBoxes, type LayoutBox } from "./layout.ts"; @@ -93,6 +94,28 @@ export interface Film { inspect?(t: number): Promise; warnings(): Promise; close(): Promise; + // How many frame or inspect calls run at once: one per page. + pages: number; +} + +// The film as one page shows it. +type PageFilm = Omit; + +export interface FilmOptions { + pageMs?: number; // time limit of each call into a page + pages?: number; // pages that draw at the same time +} + +// Pages that draw a film at the same time. Each page draws in its own renderer process, so one +// per two cores, at most 4, unless MOTION_RENDER_PAGES says otherwise. +export function capturePages(): number { + const env = process.env.MOTION_RENDER_PAGES; + if (env === undefined || env === "") + return Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); + const n = Number(env); + if (!Number.isInteger(n) || n < 1) + throw new Error(`MOTION_RENDER_PAGES must be a whole number of 1 or more, got "${env}"`); + return n; } declare global { @@ -211,7 +234,7 @@ async function openPage(browser: Browser): Promise { return page; } -async function motionFilm(page: Page, dir: string, call: Call): Promise> { +async function motionFilm(page: Page, dir: string, call: Call): Promise { await page.goto(pathToFileURL(resolve(dir, "index.html")).href + "#tools"); await page.waitForFunction(() => typeof window.__animation === "object"); const res = await call( @@ -262,7 +285,7 @@ async function waitReady(page: Page, call: Call): Promise { } } -async function htmlFilm(page: Page, dir: string, call: Call): Promise> { +async function htmlFilm(page: Page, dir: string, call: Call): Promise { const log: string[] = []; page.on("console", (m) => { if (m.type() === "warning" || m.type() === "error") log.push(`${m.type()}: ${m.text()}`); @@ -309,16 +332,53 @@ async function htmlFilm(page: Page, dir: string, call: Call): Promise { +// Runs each call on a free page, waiting for one when all are busy. A page seeks before it draws, +// so it must not take a second call before the first is done. +function onFreePage(films: PageFilm[]) { + // The pool is this closure's own state; nothing outside sees it change. + const free = [...films]; + const waiting: ((f: PageFilm) => void)[] = []; + return async (fn: (f: PageFilm) => Promise): Promise => { + const f = free.pop() ?? (await new Promise((ok) => waiting.push(ok))); + try { + return await fn(f); + } finally { + const next = waiting.shift(); + if (next) next(f); + else free.push(f); + } + }; +} + +// Opens the film on `pages` pages of one browser and returns its meta and a way to grab frames as +// PNG. Each call into a page fails after pageMs. +export async function openFilm(dir: string, opts: FilmOptions = {}): Promise { + const { pageMs = PAGE_MS, pages = 1 } = opts; + if (!Number.isInteger(pages) || pages < 1) + throw new Error(`pages must be 1 or more, got ${pages}`); checkFilmDir(dir); const browser = await launch(); const call: Call = (p, what) => limit(p, pageMs, `the film page's ${what}`); + const open = isHtmlFilm(dir) ? htmlFilm : motionFilm; try { - const page = await openPage(browser); - const film = await (isHtmlFilm(dir) ? htmlFilm : motionFilm)(page, dir, call); - return { ...film, close: () => browser.close() }; + const films = await Promise.all( + Array.from({ length: pages }, async () => open(await openPage(browser), dir, call)) + ); + const [first] = films; + if (!first) throw new Error("no film page opened"); + const run = onFreePage(films); + return { + meta: first.meta, + pages, + frame: (t) => run((f) => f.frame(t)), + inspect: + first.inspect && ((t) => run((f) => (f.inspect ? f.inspect(t) : Promise.resolve([])))), + // Every page logs the same film, so the same warning comes once. + warnings: async () => [ + ...new Set((await Promise.all(films.map((f) => f.warnings()))).flat()), + ], + close: () => browser.close(), + }; } catch (e) { await browser.close(); throw e; diff --git a/tests/html-film.test.ts b/tests/html-film.test.ts index 796db4c..e51ed13 100644 --- a/tests/html-film.test.ts +++ b/tests/html-film.test.ts @@ -233,7 +233,7 @@ describe("a stuck film page", () => { `; const html = readFileSync(join(dir, "index.html"), "utf8").replace("", stuck); writeFileSync(join(dir, "index.html"), html); - const film = await openFilm(dir, 1000); + const film = await openFilm(dir, { pageMs: 1000 }); try { expect((await film.frame(0.5)).subarray(1, 4).toString()).toBe("PNG"); await expect(film.frame(1.5)).rejects.toThrow( @@ -244,3 +244,23 @@ describe("a stuck film page", () => { } }); }); + +describe("several pages", () => { + it("draws the same frames and finds the same issues as one page", async () => { + const dir = htmlFilm(true, true); + const one = await openFilm(dir); + const two = await openFilm(dir, { pages: 2 }); + try { + expect(two.pages).toBe(2); + const times = [0.5, 1.2, 1.5, 2.5, 3.5]; + const a = await Promise.all(times.map((t) => two.frame(t))); + for (const [i, t] of times.entries()) expect(a[i]!.equals(await one.frame(t))).toBe(true); + expect((await checkLayout(two))?.map(formatIssue)).toEqual( + (await checkLayout(one))?.map(formatIssue) + ); + } finally { + await one.close(); + await two.close(); + } + }); +}); diff --git a/tests/inorder.test.ts b/tests/inorder.test.ts new file mode 100644 index 0000000..3d6f924 --- /dev/null +++ b/tests/inorder.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { inOrder } from "../skills/motion-render/scripts/inorder.ts"; + +const wait = (ms: number) => new Promise((ok) => setTimeout(ok, ms)); + +describe("inOrder", () => { + it("yields results in order, with no more than `ahead` in flight", async () => { + let running = 0, + most = 0; + const get = async (i: number) => { + running++; + most = Math.max(most, running); + await wait((5 - i) * 5); // later ones finish first + running--; + return i; + }; + const out: number[] = []; + for await (const v of inOrder(6, 3, get)) out.push(v); + expect(out).toEqual([0, 1, 2, 3, 4, 5]); + expect(most).toBe(3); + }); + + it("passes on the first failure in order", async () => { + const get = (i: number) => + i === 2 ? Promise.reject(new Error("frame 2 failed")) : Promise.resolve(i); + const out: number[] = []; + await expect( + (async () => { + for await (const v of inOrder(5, 4, get)) out.push(v); + })() + ).rejects.toThrow("frame 2 failed"); + expect(out).toEqual([0, 1]); + }); +}); diff --git a/tests/render.test.ts b/tests/render.test.ts index f76fd12..05b61e0 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { cpSync, existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; -import { checkFilmDir, openFilm } from "../skills/motion-render/scripts/render.ts"; +import { capturePages, checkFilmDir, openFilm } from "../skills/motion-render/scripts/render.ts"; import { prepare } from "../skills/motion-render/scripts/store.ts"; import { tempDir } from "./helpers.ts"; @@ -83,3 +83,31 @@ describe("motion-render command", () => { ); }); }); + +describe("capturePages", () => { + const saved = process.env.MOTION_RENDER_PAGES; + const withEnv = (v: string | undefined, fn: () => void) => { + if (v === undefined) delete process.env.MOTION_RENDER_PAGES; + else process.env.MOTION_RENDER_PAGES = v; + try { + fn(); + } finally { + if (saved === undefined) delete process.env.MOTION_RENDER_PAGES; + else process.env.MOTION_RENDER_PAGES = saved; + } + }; + + it("uses MOTION_RENDER_PAGES, or 1 to 4 pages from the cores", () => { + withEnv("3", () => expect(capturePages()).toBe(3)); + withEnv(undefined, () => { + const n = capturePages(); + expect(n).toBeGreaterThanOrEqual(1); + expect(n).toBeLessThanOrEqual(4); + }); + }); + + it("rejects a value that is not a whole number of 1 or more", () => { + for (const v of ["0", "-1", "1.5", "two"]) + withEnv(v, () => expect(() => capturePages()).toThrow(/MOTION_RENDER_PAGES must be/)); + }); +}); From 826bc25d48af4d72cd40e64b27dc75bcab977d30 Mon Sep 17 00:00:00 2001 From: umiyosh Date: Mon, 26 Oct 2026 00:25:04 +0900 Subject: [PATCH 3/4] perf(build): give each capture page its own browser and use up to 8 --- skills/motion-render/scripts/render.ts | 29 ++++++++++++++++---------- tests/render.test.ts | 4 ++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/skills/motion-render/scripts/render.ts b/skills/motion-render/scripts/render.ts index a6f42ea..566fa78 100644 --- a/skills/motion-render/scripts/render.ts +++ b/skills/motion-render/scripts/render.ts @@ -106,12 +106,13 @@ export interface FilmOptions { pages?: number; // pages that draw at the same time } -// Pages that draw a film at the same time. Each page draws in its own renderer process, so one -// per two cores, at most 4, unless MOTION_RENDER_PAGES says otherwise. +// Pages that draw a film at the same time. Each page has its own browser and keeps about one core +// busy (capture speed grew in step with the pages up to 8), so one per two cores, at most 8, which +// leaves the rest to ffmpeg and whatever else runs. MOTION_RENDER_PAGES overrides it. export function capturePages(): number { const env = process.env.MOTION_RENDER_PAGES; if (env === undefined || env === "") - return Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); + return Math.max(1, Math.min(8, Math.floor(availableParallelism() / 2))); const n = Number(env); if (!Number.isInteger(n) || n < 1) throw new Error(`MOTION_RENDER_PAGES must be a whole number of 1 or more, got "${env}"`); @@ -350,20 +351,26 @@ function onFreePage(films: PageFilm[]) { }; } -// Opens the film on `pages` pages of one browser and returns its meta and a way to grab frames as -// PNG. Each call into a page fails after pageMs. +// Opens the film on `pages` pages and returns its meta and a way to grab frames as PNG. Each page +// gets its own browser: the pages of one browser draw one at a time (2 or 4 pages in one browser +// captured no faster than 1), while separate browsers draw side by side. Each call into a page +// fails after pageMs. export async function openFilm(dir: string, opts: FilmOptions = {}): Promise { const { pageMs = PAGE_MS, pages = 1 } = opts; if (!Number.isInteger(pages) || pages < 1) throw new Error(`pages must be 1 or more, got ${pages}`); checkFilmDir(dir); - const browser = await launch(); + // The first launch may download Chromium, so the others wait for it. + const browsers = [await launch()]; + const closeAll = () => Promise.all(browsers.map((b) => b.close())).then(() => undefined); const call: Call = (p, what) => limit(p, pageMs, `the film page's ${what}`); const open = isHtmlFilm(dir) ? htmlFilm : motionFilm; try { - const films = await Promise.all( - Array.from({ length: pages }, async () => open(await openPage(browser), dir, call)) - ); + const more = await Promise.allSettled(Array.from({ length: pages - 1 }, () => launch())); + for (const r of more) if (r.status === "fulfilled") browsers.push(r.value); + const failed = more.find((r) => r.status === "rejected"); + if (failed?.status === "rejected") throw failed.reason; + const films = await Promise.all(browsers.map(async (b) => open(await openPage(b), dir, call))); const [first] = films; if (!first) throw new Error("no film page opened"); const run = onFreePage(films); @@ -377,10 +384,10 @@ export async function openFilm(dir: string, opts: FilmOptions = {}): Promise [ ...new Set((await Promise.all(films.map((f) => f.warnings()))).flat()), ], - close: () => browser.close(), + close: closeAll, }; } catch (e) { - await browser.close(); + await closeAll(); throw e; } } diff --git a/tests/render.test.ts b/tests/render.test.ts index 05b61e0..e0d4d84 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -97,12 +97,12 @@ describe("capturePages", () => { } }; - it("uses MOTION_RENDER_PAGES, or 1 to 4 pages from the cores", () => { + it("uses MOTION_RENDER_PAGES, or 1 to 8 pages from the cores", () => { withEnv("3", () => expect(capturePages()).toBe(3)); withEnv(undefined, () => { const n = capturePages(); expect(n).toBeGreaterThanOrEqual(1); - expect(n).toBeLessThanOrEqual(4); + expect(n).toBeLessThanOrEqual(8); }); }); From 1614bb51f820315a741e16686915d34398d3179d Mon Sep 17 00:00:00 2001 From: umiyosh Date: Mon, 26 Oct 2026 00:52:55 +0900 Subject: [PATCH 4/4] docs: describe MOTION_RENDER_PAGES --- README.ja.md | 15 ++++++++------- README.md | 15 ++++++++------- skills/motion-render/scripts/cli.ts | 3 ++- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/README.ja.md b/README.ja.md index 8aeb8ca..c2b8204 100644 --- a/README.ja.md +++ b/README.ja.md @@ -150,13 +150,14 @@ auto mode を使っていて、それでも実行が拒否される場合は、` | `motion-render build [out.mp4]` | ナレーション・BGM・効果音を合成し、映像と合わせて MP4 にする。どれかが足りないとき、HTML の映像が配置のチェックに通らないときは止まる | | `motion-render --version` | 版を表示する | -| 環境変数 | 効果 | -| ----------------------- | ------------------------------------------------------------ | -| `GEMINI_API_KEY` | ナレーションと BGM の生成に必要 | -| `ELEVENLABS_API_KEY` | 効果音の生成に必要(無ければ効果音を付けない) | -| `MOTION_RENDER_OUT_DIR` | `build` の出力先を省略したときの保存先 | -| `CHROMIUM_PATH` | 同梱の Chromium の代わりに、手元の Chrome や Chromium を使う | -| `FFMPEG_PATH` | 同梱の ffmpeg の代わりに、手元の ffmpeg を使う | +| 環境変数 | 効果 | +| ----------------------- | ------------------------------------------------------------------------------------------ | +| `GEMINI_API_KEY` | ナレーションと BGM の生成に必要 | +| `ELEVENLABS_API_KEY` | 効果音の生成に必要(無ければ効果音を付けない) | +| `MOTION_RENDER_OUT_DIR` | `build` の出力先を省略したときの保存先 | +| `CHROMIUM_PATH` | 同梱の Chromium の代わりに、手元の Chrome や Chromium を使う | +| `FFMPEG_PATH` | 同梱の ffmpeg の代わりに、手元の ffmpeg を使う | +| `MOTION_RENDER_PAGES` | 同時にコマを描くブラウザの数(既定: コア数の半分、最大 8)。マシンが混んでいるときは減らす | ## 困ったとき diff --git a/README.md b/README.md index a2df9d7..6719469 100644 --- a/README.md +++ b/README.md @@ -150,13 +150,14 @@ Each video uses one work directory (``). Its layout and the `script.json` | `motion-render build [out.mp4]` | Mixes narration, BGM and sound effects and renders the MP4. Stops if any is missing, or if an HTML film fails the layout check | | `motion-render --version` | Prints the version | -| Environment variable | Effect | -| ----------------------- | ---------------------------------------------------------- | -| `GEMINI_API_KEY` | Required for narration and BGM | -| `ELEVENLABS_API_KEY` | Required for sound effects (none are added without it) | -| `MOTION_RENDER_OUT_DIR` | Default output directory when `build` gets no output path | -| `CHROMIUM_PATH` | Use your own Chrome or Chromium instead of the bundled one | -| `FFMPEG_PATH` | Use your own ffmpeg instead of the bundled one | +| Environment variable | Effect | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| `GEMINI_API_KEY` | Required for narration and BGM | +| `ELEVENLABS_API_KEY` | Required for sound effects (none are added without it) | +| `MOTION_RENDER_OUT_DIR` | Default output directory when `build` gets no output path | +| `CHROMIUM_PATH` | Use your own Chrome or Chromium instead of the bundled one | +| `FFMPEG_PATH` | Use your own ffmpeg instead of the bundled one | +| `MOTION_RENDER_PAGES` | Browsers that draw frames at once (default: half the cores, at most 8). Lower it if the machine is busy | ## Troubleshooting diff --git a/skills/motion-render/scripts/cli.ts b/skills/motion-render/scripts/cli.ts index 55d9e2d..4b63193 100755 --- a/skills/motion-render/scripts/cli.ts +++ b/skills/motion-render/scripts/cli.ts @@ -41,7 +41,8 @@ env: ELEVENLABS_API_KEY required by sfx MOTION_RENDER_OUT_DIR default output directory for build CHROMIUM_PATH use this Chrome/Chromium instead of the bundled one - FFMPEG_PATH use this ffmpeg instead of the bundled one`; + FFMPEG_PATH use this ffmpeg instead of the bundled one + MOTION_RENDER_PAGES browsers that draw frames at once (default: half the cores, at most 8)`; function die(msg: string, code = 1): never { console.error(`motion-render: ${msg}`);