Files
olivv-cs-ComfyUI-FunPack/tools/showcase/capture.mjs
T
digital-garbageandClaude Opus 4.8 89945bdb5e tools: showcase generator — auto gallery + walkthrough video from the tour
Drives the editor's built-in tour (?mode=tour, fully mocked — no backend/models/
GPU) with Playwright: serves the static frontend, walks window.TourGuide, and
screenshots each step (annotated with its spotlight + caption) while recording a
walkthrough video. Emits NN-*.png stills, an index.md gallery, and
walkthrough.webm/.mp4. Self-maintaining: new tour steps appear automatically.

No more screenshotting each button by hand. node_modules/ and out/ are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 04:39:04 +03:00

147 lines
6.0 KiB
JavaScript

// Auto-generate a FunPack Cutting Room feature showcase — an annotated screenshot
// gallery + a walkthrough video — by driving the built-in in-app tour.
//
// Why this works with zero effort: loading the editor with `?mode=tour` boots a fully
// MOCKED demo (the tour stubs the whole API, media bin, models, shortcuts, overlays),
// so the UI renders complete with NO ComfyUI backend, NO models, NO GPU. The tour is a
// list of steps that each open the relevant panel, spotlight a target, and carry an
// authored caption — exactly the per-feature material we want. We just serve the static
// frontend, walk `window.TourGuide`, and screenshot/record each step.
//
// Self-maintaining: add a tour step → it appears in the showcase automatically.
//
// cd tools/showcase && npm install && npx playwright install chromium && npm run capture
//
// Output (gitignored): tools/showcase/out/ — NN-*.png stills, index.md gallery,
// walkthrough.webm and (if ffmpeg is present) walkthrough.mp4.
import { chromium } from "playwright";
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRONTEND = path.resolve(__dirname, "../../movie_editor/frontend");
const OUT = path.resolve(__dirname, "out");
const VIEW = { width: 1440, height: 900 };
const SETTLE_MS = 1200; // let a step's panel/animation settle before the shot (mocked → fast)
const MIME = {
".html": "text/html", ".js": "text/javascript", ".css": "text/css",
".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".map": "application/json",
};
// Minimal static server for the frontend dir — no extra dependency, no ComfyUI needed.
function serve(dir) {
const server = http.createServer((req, res) => {
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
const fp = path.normalize(path.join(dir, urlPath === "/" ? "/index.html" : urlPath));
if (!fp.startsWith(dir)) { res.writeHead(403); return res.end("forbidden"); }
fs.readFile(fp, (err, data) => {
if (err) { res.writeHead(404); return res.end("not found"); }
res.writeHead(200, { "content-type": MIME[path.extname(fp).toLowerCase()] || "application/octet-stream" });
res.end(data);
});
});
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server)));
}
const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "step";
async function main() {
if (!fs.existsSync(path.join(FRONTEND, "index.html"))) {
throw new Error(`frontend not found at ${FRONTEND}`);
}
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(OUT, { recursive: true });
const server = await serve(FRONTEND);
const { port } = server.address();
const url = `http://127.0.0.1:${port}/index.html?mode=tour`;
console.log(`serving ${FRONTEND} → ${url}`);
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: VIEW,
deviceScaleFactor: 2,
recordVideo: { dir: OUT, size: VIEW },
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "load" });
// Wait for the editor shell + tour singleton to be ready.
await page.waitForFunction(
() => window.TourGuide && window.Store && document.getElementById("workspace"),
null, { timeout: 30000 },
);
await page.waitForTimeout(1500);
// Mount the tour and discover the step count from the card header ("1 / N").
const count = await page.evaluate(() => {
window.TourGuide.start(0);
const t = document.querySelector(".tour-step-no")?.textContent || "1 / 1";
return parseInt((t.split("/")[1] || "1").trim(), 10) || 1;
});
console.log(`tour has ${count} steps`);
const steps = [];
for (let i = 0; i < count; i++) {
const meta = await page.evaluate((idx) => {
window.TourGuide.jump(idx);
return {
title: document.querySelector(".tour-card-title")?.textContent || `Step ${idx + 1}`,
body: document.querySelector(".tour-card-body")?.textContent || "",
};
}, i);
await page.waitForTimeout(SETTLE_MS);
const file = `${String(i + 1).padStart(2, "0")}-${slug(meta.title)}.png`;
await page.screenshot({ path: path.join(OUT, file) });
steps.push({ ...meta, file });
console.log(` ${i + 1}/${count} ${meta.title}`);
}
await page.waitForTimeout(800); // tail for the video
const video = page.video();
await context.close(); // finalizes the recording
await browser.close();
server.close();
let webm = null;
if (video) {
webm = path.join(OUT, "walkthrough.webm");
try { fs.renameSync(await video.path(), webm); } catch (_) { webm = null; }
}
// Gallery index.
const md = [
"# FunPack Cutting Room — feature showcase",
"",
"_Auto-generated from the in-app tour by `tools/showcase/capture.mjs`. No models or GPU required — re-run after UI changes to refresh._",
"",
webm ? "**Walkthrough:** `walkthrough.mp4` (or `walkthrough.webm`)\n" : "",
...steps.map((s, i) => `## ${i + 1}. ${s.title}\n\n![${s.title}](${s.file})\n\n${s.body}\n`),
].join("\n");
fs.writeFileSync(path.join(OUT, "index.md"), md);
// Optional webm → mp4 (web-friendly) if ffmpeg is on PATH.
if (webm) {
try {
execFileSync("ffmpeg", [
"-y", "-i", webm, "-movflags", "faststart", "-pix_fmt", "yuv420p",
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", path.join(OUT, "walkthrough.mp4"),
], { stdio: "ignore" });
console.log("wrote walkthrough.mp4");
} catch (_) {
console.log("ffmpeg not found — keeping walkthrough.webm only");
}
}
console.log(`\nDone — ${steps.length} stills${webm ? " + walkthrough video" : ""} in ${OUT}`);
}
main().catch((e) => { console.error(e); process.exit(1); });