File size: 5,549 Bytes
3bc016b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | /**
* 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)`);
}
|