Skip to content

Commit 8ed6f31

Browse files
Add apb shoot QC command; fix footer logo alignment
shoot screenshots every slide at full HD (serves the deck, drives headless Chrome, disables transitions/fragments during capture). styles.css: footer images align bottom-left for a branding logo.
1 parent 859c86a commit 8ed6f31

4 files changed

Lines changed: 221 additions & 1 deletion

File tree

bin/apb.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const USAGE = [
2020
" validate <deck.json> [--json] Check a deck against the JSON schema",
2121
" present <deck.json> [--open] [--port N] Serve the deck on a local presentation server",
2222
" export <deck.json> [--format ...] [...] Export the deck (pdf/pptx)",
23+
" shoot <deck.json> [--out dir] [...] Screenshot every slide at full HD for QC",
2324
"",
2425
"Run a command with --help for its full option list.",
2526
].join("\n");
@@ -40,6 +41,10 @@ async function dispatch() {
4041
const { main } = await import("../scripts/export.js");
4142
return main(rest);
4243
}
44+
case "shoot": {
45+
const { main } = await import("../scripts/shoot.js");
46+
return main(rest);
47+
}
4348
case "--help":
4449
case "-h":
4550
case undefined:

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "agentic-presentation-builder",
3-
"version": "0.1.7",
3+
"version": "0.1.8",
44
"description": "LLM-friendly JSON-based presentation engine that renders to interactive JS presentations",
55
"main": "src/index.js",
66
"type": "module",
@@ -13,6 +13,7 @@
1313
"preview": "vite preview",
1414
"present": "node scripts/present.js",
1515
"export": "node scripts/export.js",
16+
"shoot": "node scripts/shoot.js",
1617
"docs:serve": "uvx --with-requirements docs/requirements.txt mkdocs serve",
1718
"docs:build": "uvx --with-requirements docs/requirements.txt mkdocs build --strict",
1819
"test": "node --test",

scripts/shoot.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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+
}

src/styles.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,13 @@ body.export-mode .presentation-settings {
293293
margin-top: auto;
294294
}
295295

296+
/* House rule: a branding logo placed in the footer sits bottom-LEFT, not centered.
297+
.image-element hardcodes align-items:center; override it for footer images only. */
298+
.slide-footer .image-element {
299+
align-items: flex-start;
300+
text-align: left;
301+
}
302+
296303
/* Two-column layout */
297304
.two-column-layout {
298305
flex: 1 1 auto;

0 commit comments

Comments
 (0)