sergiopaniego's picture
sergiopaniego HF Staff
Publish the deck source under source/ so the next deck can start from it
3bc016b verified
Raw History Blame Contribute Delete
5.55 kB
/**
* Export the deck to a PDF and a PPTX, one page per slide, 1280x720 landscape.
*
* Both come out of a single walk of the deck, because the expensive part is
* waiting: the deck is a single-page app with entry animations, and the figures
* are iframes that have to lay out before anything is captured.
*
* The PPTX is one full-bleed image per slide rather than editable shapes. That
* is deliberate: the figures are sandboxed iframes, so there is nothing to
* convert into native shapes, and an image per slide is what survives an import
* into Google Slides, Keynote or PowerPoint intact.
*
* npm run build && npm run export
*
* Outputs land in public/ so they ship with the Space and the settings panel
* can link straight at them.
*
* Env: PORT (default 7899), SCALE (default 2, the PPTX image density), SLIDES
* (default: read from the built bundle).
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, stat, mkdir, writeFile } from "node:fs/promises";
import { join, extname, dirname, resolve } from "node:path";
import { PDFDocument } from "pdf-lib";
import PptxGenJS from "pptxgenjs";
const ROOT = resolve(dirname(new URL(import.meta.url).pathname), "..");
const DIST = join(ROOT, "dist");
const OUTDIR = join(ROOT, "public");
const NAME = "multi-harness-rl-slides";
const PORT = Number(process.env.PORT || 7899);
const SCALE = Number(process.env.SCALE || 2);
const MIME = {
".html": "text/html", ".js": "text/javascript", ".css": "text/css",
".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml",
".jpg": "image/jpeg", ".webp": "image/webp", ".pdf": "application/pdf",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
};
// A static server for dist/, so the export runs against exactly what ships.
function serve() {
return new Promise((ok) => {
const s = createServer(async (req, res) => {
const path = decodeURIComponent((req.url || "/").split("?")[0]);
let file = join(DIST, path === "/" ? "index.html" : path);
try {
if ((await stat(file)).isDirectory()) file = join(file, "index.html");
} catch {
file = join(DIST, "index.html"); // SPA fallback
}
try {
const body = await readFile(file);
res.writeHead(200, { "Content-Type": MIME[extname(file)] || "application/octet-stream" });
res.end(body);
} catch {
res.writeHead(404).end("not found");
}
});
s.listen(PORT, () => ok(s));
});
}
const server = await serve();
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: SCALE });
await page.goto(`http://localhost:${PORT}/`, { waitUntil: "networkidle" });
// start at the first slide regardless of what the last session left behind
await page.evaluate(() => localStorage.setItem("rlenv-slides-index", "0"));
await page.reload({ waitUntil: "networkidle" });
const total = await page.evaluate(() => {
const m = document.body.innerText.match(/SLIDES\s*·\s*(\d+)/);
return m ? Number(m[1]) : null;
});
const count = Number(process.env.SLIDES || total || 35);
console.log(`exporting ${count} slides`);
const pdfPages = [];
const shots = [];
for (let i = 0; i < count; i++) {
// let the entry animation finish and every figure iframe lay out
await page.waitForTimeout(3200);
await page.evaluate(async () => {
await Promise.all([...document.querySelectorAll("iframe")].map((f) =>
f.contentDocument && f.contentDocument.readyState === "complete"
? Promise.resolve()
: new Promise((r) => f.addEventListener("load", r, { once: true }))));
});
pdfPages.push(await page.pdf({
width: "1280px", height: "720px", printBackground: true,
pageRanges: "1", margin: { top: 0, right: 0, bottom: 0, left: 0 },
}));
// JPEG rather than PNG: a 35-slide deck of 2560x1440 PNGs is an order of
// magnitude larger than the whole Space, and these are photographs of a
// rendered page, not line art that needs lossless.
// no `scale` option, so the shot comes out at the device scale (2560x1440),
// which is what keeps the type crisp on a projector.
shots.push(await page.screenshot({ type: "jpeg", quality: 90, clip: { x: 0, y: 0, width: 1280, height: 720 } }));
process.stdout.write(` ${i + 1}/${count}\r`);
if (i < count - 1) await page.keyboard.press("ArrowRight");
}
await browser.close();
server.close();
await mkdir(OUTDIR, { recursive: true });
// stitch the single-page PDFs into one deck
const out = await PDFDocument.create();
for (const buf of pdfPages) {
const src = await PDFDocument.load(buf);
const [p] = await out.copyPages(src, [0]);
out.addPage(p);
}
const pdfPath = join(OUTDIR, `${NAME}.pdf`);
await writeFile(pdfPath, await out.save());
// one full-bleed image per PPTX slide, 16:9 at the standard 13.333 x 7.5 in
const pptx = new PptxGenJS();
pptx.layout = "LAYOUT_16x9";
pptx.title = "Training a coding agent through a harness you did not write";
pptx.author = "Sergio Paniego Blanco";
for (const shot of shots) {
const s = pptx.addSlide();
s.background = { color: "07090F" };
s.addImage({ data: `image/jpeg;base64,${shot.toString("base64")}`, x: 0, y: 0, w: 13.333, h: 7.5 });
}
const pptxPath = join(OUTDIR, `${NAME}.pptx`);
await pptx.writeFile({ fileName: pptxPath });
for (const p of [pdfPath, pptxPath]) {
const { size } = await stat(p);
console.log(`\nwrote ${p} (${(size / 1e6).toFixed(1)} MB)`);
}