|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * apb shoot -- screenshot every slide of a deck at full HD for visual QC. |
| 4 | + * |
| 5 | + * `apb validate` passes decks that still render a blank mermaid or clipped code; |
| 6 | + * the only reliable QC is to look at every slide. This serves the deck (reusing |
| 7 | + * the present server) and drives headless Chrome through it, writing one PNG per |
| 8 | + * slide. Transitions and fragments are disabled during capture so an unsettled |
| 9 | + * slide-transform never fakes a right-edge clip and every animated element shows. |
| 10 | + * |
| 11 | + * apb shoot deck.json --out ./qc |
| 12 | + * apb shoot deck.json --out ./qc --width 1920 --height 1080 |
| 13 | + */ |
| 14 | + |
| 15 | +import { existsSync, mkdirSync } from "node:fs"; |
| 16 | +import { join, resolve } from "node:path"; |
| 17 | +import { fileURLToPath } from "node:url"; |
| 18 | +import puppeteer from "puppeteer-core"; |
| 19 | +import { startLocalPresentationServer } from "../src/utils/local-presentation-server.js"; |
| 20 | + |
| 21 | +const DEFAULT_CHROME_PATHS = [ |
| 22 | + process.env.PUPPETEER_EXECUTABLE_PATH, |
| 23 | + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", |
| 24 | + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", |
| 25 | + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", |
| 26 | + "/usr/bin/google-chrome", |
| 27 | + "/usr/bin/chromium-browser", |
| 28 | + "/usr/bin/chromium", |
| 29 | +].filter(Boolean); |
| 30 | + |
| 31 | +const USAGE = [ |
| 32 | + "Usage: apb shoot <deck.json> [options]", |
| 33 | + "", |
| 34 | + "Screenshot every slide at full resolution for visual QC.", |
| 35 | + "", |
| 36 | + "Options:", |
| 37 | + " --out <dir> Output directory for PNGs (default: ./apb-screenshots)", |
| 38 | + " --width <px> Viewport width (default: 1920)", |
| 39 | + " --height <px> Viewport height (default: 1080)", |
| 40 | + " --wait <ms> Settle delay per slide before capture (default: 800)", |
| 41 | + " --chrome-path <p> Path to a Chrome/Edge executable (else auto-detected)", |
| 42 | + " --port <n> Port for the temporary present server (default: ephemeral)", |
| 43 | + " --host <h> Host for the temporary present server", |
| 44 | + " --help Show this help", |
| 45 | +].join("\n"); |
| 46 | + |
| 47 | +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 48 | + |
| 49 | +function resolveChromeExecutablePath(explicitPath) { |
| 50 | + if (explicitPath) { |
| 51 | + if (!existsSync(explicitPath)) { |
| 52 | + throw new Error(`Chrome executable not found at ${explicitPath}`); |
| 53 | + } |
| 54 | + return explicitPath; |
| 55 | + } |
| 56 | + for (const candidate of DEFAULT_CHROME_PATHS) { |
| 57 | + if (candidate && existsSync(candidate)) return candidate; |
| 58 | + } |
| 59 | + throw new Error( |
| 60 | + "Could not find a Chrome-compatible browser. Pass --chrome-path to a local Chrome or Edge executable.", |
| 61 | + ); |
| 62 | +} |
| 63 | + |
| 64 | +function parseArgs(argv) { |
| 65 | + const args = { |
| 66 | + presentationPath: undefined, |
| 67 | + out: "./apb-screenshots", |
| 68 | + width: 1920, |
| 69 | + height: 1080, |
| 70 | + wait: 800, |
| 71 | + chromePath: undefined, |
| 72 | + port: undefined, |
| 73 | + host: undefined, |
| 74 | + help: false, |
| 75 | + }; |
| 76 | + for (let i = 0; i < argv.length; i += 1) { |
| 77 | + const a = argv[i]; |
| 78 | + const next = () => argv[(i += 1)]; |
| 79 | + switch (a) { |
| 80 | + case "--help": |
| 81 | + case "-h": args.help = true; break; |
| 82 | + case "--out": args.out = next(); break; |
| 83 | + case "--width": args.width = Number(next()); break; |
| 84 | + case "--height": args.height = Number(next()); break; |
| 85 | + case "--wait": args.wait = Number(next()); break; |
| 86 | + case "--chrome-path": args.chromePath = next(); break; |
| 87 | + case "--port": args.port = Number(next()); break; |
| 88 | + case "--host": args.host = next(); break; |
| 89 | + default: |
| 90 | + if (a.startsWith("-")) throw new Error(`Unknown option: ${a}`); |
| 91 | + if (!args.presentationPath) args.presentationPath = a; |
| 92 | + else throw new Error(`Unexpected argument: ${a}`); |
| 93 | + } |
| 94 | + } |
| 95 | + return args; |
| 96 | +} |
| 97 | + |
| 98 | +export async function main(argv = process.argv.slice(2)) { |
| 99 | + let args; |
| 100 | + try { |
| 101 | + args = parseArgs(argv); |
| 102 | + } catch (error) { |
| 103 | + console.error(`${error.message}\n`); |
| 104 | + console.error(USAGE); |
| 105 | + process.exit(1); |
| 106 | + } |
| 107 | + |
| 108 | + if (args.help) { |
| 109 | + console.log(USAGE); |
| 110 | + return; |
| 111 | + } |
| 112 | + if (!args.presentationPath) { |
| 113 | + console.error("Missing presentation JSON path.\n"); |
| 114 | + console.error(USAGE); |
| 115 | + process.exit(1); |
| 116 | + } |
| 117 | + |
| 118 | + let runtime; |
| 119 | + try { |
| 120 | + runtime = await startLocalPresentationServer({ |
| 121 | + host: args.host, |
| 122 | + open: false, |
| 123 | + port: args.port, |
| 124 | + presentationPath: args.presentationPath, |
| 125 | + }); |
| 126 | + } catch (error) { |
| 127 | + if (error.validationResult) { |
| 128 | + console.error(`${error.message}\n`); |
| 129 | + error.validationResult.errors.forEach((e, idx) => |
| 130 | + console.error(`${idx + 1}. ${e.path || "root"}: ${e.message}`), |
| 131 | + ); |
| 132 | + process.exit(1); |
| 133 | + } |
| 134 | + throw error; |
| 135 | + } |
| 136 | + |
| 137 | + const { presentationUrl, presentationPath, server } = runtime; |
| 138 | + const outDir = resolve(args.out); |
| 139 | + mkdirSync(outDir, { recursive: true }); |
| 140 | + console.log(`Shooting: ${presentationPath}`); |
| 141 | + console.log(`Output: ${outDir} (${args.width}x${args.height})`); |
| 142 | + |
| 143 | + let browser; |
| 144 | + try { |
| 145 | + browser = await puppeteer.launch({ |
| 146 | + executablePath: resolveChromeExecutablePath(args.chromePath), |
| 147 | + headless: true, |
| 148 | + args: ["--no-sandbox", "--force-device-scale-factor=1"], |
| 149 | + }); |
| 150 | + const page = await browser.newPage(); |
| 151 | + await page.setViewport({ width: args.width, height: args.height, deviceScaleFactor: 1 }); |
| 152 | + await page.goto(presentationUrl, { waitUntil: "load" }); |
| 153 | + await page.waitForFunction( |
| 154 | + "window.Reveal && window.Reveal.isReady && window.Reveal.isReady()", |
| 155 | + { timeout: 20000 }, |
| 156 | + ); |
| 157 | + // Disable transitions (so an unsettled transform can't fake a clip) and |
| 158 | + // fragments (so every animated element is captured in one shot). |
| 159 | + await page.evaluate(() => window.Reveal.configure({ fragments: false, transition: "none" })); |
| 160 | + await delay(600); |
| 161 | + |
| 162 | + const total = await page.evaluate(() => window.Reveal.getTotalSlides()); |
| 163 | + for (let i = 0; i < total; i += 1) { |
| 164 | + await page.evaluate((n) => window.Reveal.slide(n), i); |
| 165 | + await delay(args.wait); |
| 166 | + // mermaid renders asynchronously -- wait for its SVG before shooting |
| 167 | + await page |
| 168 | + .waitForFunction( |
| 169 | + () => { |
| 170 | + const sec = |
| 171 | + document.querySelector("section.present") || document.querySelector("section"); |
| 172 | + const m = sec && sec.querySelector(".mermaid"); |
| 173 | + return !m || !!m.querySelector("svg"); |
| 174 | + }, |
| 175 | + { timeout: 6000 }, |
| 176 | + ) |
| 177 | + .catch(() => {}); |
| 178 | + await delay(400); |
| 179 | + const id = await page.evaluate(() => (window.Reveal.getCurrentSlide() || {}).id || ""); |
| 180 | + const name = `slide-${String(i + 1).padStart(3, "0")}${id ? `-${id}` : ""}.png`; |
| 181 | + await page.screenshot({ path: join(outDir, name) }); |
| 182 | + console.log(` + ${name}`); |
| 183 | + } |
| 184 | + console.log(`\nWrote ${total} screenshots to ${outDir}`); |
| 185 | + } finally { |
| 186 | + try { |
| 187 | + await browser?.close(); |
| 188 | + } catch (e) { |
| 189 | + console.warn(`Browser cleanup failed: ${e.message}`); |
| 190 | + } |
| 191 | + try { |
| 192 | + await server?.close(); |
| 193 | + } catch (e) { |
| 194 | + console.warn(`Server cleanup failed: ${e.message}`); |
| 195 | + } |
| 196 | + } |
| 197 | +} |
| 198 | + |
| 199 | +const isEntrypoint = |
| 200 | + process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); |
| 201 | + |
| 202 | +if (isEntrypoint) { |
| 203 | + main().catch((error) => { |
| 204 | + console.error(error.stack || error.message); |
| 205 | + process.exit(1); |
| 206 | + }); |
| 207 | +} |
0 commit comments