slidemind 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ // Local slide capture — run the add-in's shipped capture engine on THIS
2
+ // machine's Chrome.
3
+ //
4
+ // The slow half of a render (headless-Chrome DOM capture) used to run in the
5
+ // cloud and die at the 240s ingress cap. Now the add-in ships its capture
6
+ // engine on the bundle wire (materialized as `<bundleDir>/capture-engine.mjs`,
7
+ // like the lint engine); we dynamic-import it and run it locally with a
8
+ // puppeteer Chrome pinned to the SAME version the add-in uses (identical
9
+ // Chromium ⇒ identical text measurement). The result — a serializable
10
+ // CapturedDeck — is POSTed to the server's `/translate` route, which keeps the
11
+ // IP-sensitive boxes→OOXML half. This repo owns NO capture/translate source.
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+
17
+ // Same launch profile as the add-in's chrome-launcher.ts, so the client browser
18
+ // behaves identically to the server's during capture.
19
+ const LAUNCH_ARGS = [
20
+ "--no-sandbox",
21
+ "--disable-gpu",
22
+ "--hide-scrollbars",
23
+ "--disable-features=AppShimRemoteCocoa,AppShimNewCloseBehavior,WebAppSystemMediaControls",
24
+ "--disable-default-apps",
25
+ "--disable-extensions",
26
+ "--no-first-run",
27
+ "--no-default-browser-check",
28
+ ];
29
+
30
+ /** True when the materialized bundle carries a capture engine to run locally. */
31
+ export function bundleHasCaptureEngine(bundleDir) {
32
+ if (!bundleDir) return false;
33
+ return fs.existsSync(path.join(bundleDir, "capture-engine.mjs"));
34
+ }
35
+
36
+ function readBundleMeta(bundleDir) {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(path.join(bundleDir, "bundle.json"), "utf8"));
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ function readJsxDir(dir) {
45
+ const out = {};
46
+ if (!fs.existsSync(dir)) return out;
47
+ for (const name of fs.readdirSync(dir)) {
48
+ if (!name.endsWith(".jsx")) continue;
49
+ out[name] = fs.readFileSync(path.join(dir, name), "utf8");
50
+ }
51
+ return out;
52
+ }
53
+
54
+ // Walk a directory into { "<rel>": "<base64>" }, rel relative to `dir` with
55
+ // forward slashes (the keys the capture engine maps under assets/).
56
+ function readAssetsAsBase64(dir, out, prefix = "") {
57
+ if (!fs.existsSync(dir)) return out;
58
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
59
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
60
+ const abs = path.join(dir, entry.name);
61
+ if (entry.isDirectory()) {
62
+ readAssetsAsBase64(abs, out, rel);
63
+ } else if (!(rel in out)) {
64
+ // First writer wins → bundle assets (added first) shadow deck assets on a
65
+ // name clash, matching the server's "brand asset wins" rule.
66
+ out[rel] = fs.readFileSync(abs).toString("base64");
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ // Build the in-memory workspace the capture engine renders: the deck's slides +
73
+ // the materialized bundle's layouts / css / assets / fonts / primitives.
74
+ // Exported for tests (pure: filesystem in, plain JSON out).
75
+ export function buildCaptureInput(deckDir, bundleDir) {
76
+ const meta = readBundleMeta(bundleDir);
77
+
78
+ const slides = readJsxDir(path.join(deckDir, "slides"));
79
+ const layouts = readJsxDir(path.join(bundleDir, "layouts"));
80
+
81
+ const layoutsCssPath = path.join(bundleDir, "layouts", "layouts.css");
82
+ const layoutsCss = fs.existsSync(layoutsCssPath)
83
+ ? fs.readFileSync(layoutsCssPath, "utf8")
84
+ : "";
85
+
86
+ const corporateCssPath = path.join(bundleDir, "corporate-design.css");
87
+ const corporateCss = fs.existsSync(corporateCssPath)
88
+ ? fs.readFileSync(corporateCssPath, "utf8")
89
+ : "";
90
+
91
+ // Bundle (brand) assets first so they win a name clash, then deck-local.
92
+ const assets = {};
93
+ readAssetsAsBase64(path.join(bundleDir, "assets"), assets);
94
+ readAssetsAsBase64(path.join(deckDir, "assets"), assets);
95
+
96
+ // Fonts: role/family from the bundle meta manifest, bytes from fonts/<file>.
97
+ const fonts = [];
98
+ for (const f of Array.isArray(meta.fonts) ? meta.fonts : []) {
99
+ const fontPath = path.join(bundleDir, "fonts", path.basename(f.filename));
100
+ if (!fs.existsSync(fontPath)) continue;
101
+ fonts.push({
102
+ role: f.role,
103
+ family: f.family,
104
+ filename: path.basename(f.filename),
105
+ bytesB64: fs.readFileSync(fontPath).toString("base64"),
106
+ });
107
+ }
108
+
109
+ let primitives = { version: 2, primitives: [] };
110
+ try {
111
+ primitives = JSON.parse(
112
+ fs.readFileSync(path.join(bundleDir, "primitives.json"), "utf8"),
113
+ );
114
+ } catch {
115
+ /* empty registry is fine */
116
+ }
117
+
118
+ return {
119
+ slides,
120
+ layouts,
121
+ layoutsCss,
122
+ corporateCss,
123
+ assets,
124
+ fonts,
125
+ primitives,
126
+ deckName: path.basename(deckDir),
127
+ };
128
+ }
129
+
130
+ // Launch a headless Chrome, run `fn`, then tear it down — mirrors the add-in's
131
+ // withBrowser (waits for the process to fully exit before cleanup).
132
+ async function withBrowser(fn) {
133
+ // Test hook: run the engine with no real browser. Only safe with a fake
134
+ // engine that ignores its `browser` argument — never set in production.
135
+ if (process.env.SLIDEMIND_CAPTURE_BROWSERLESS === "1") {
136
+ return fn(null);
137
+ }
138
+ const puppeteer = (await import("puppeteer")).default;
139
+ const browser = await puppeteer.launch({
140
+ headless: true,
141
+ protocolTimeout: 600_000,
142
+ args: LAUNCH_ARGS,
143
+ });
144
+ try {
145
+ return await fn(browser);
146
+ } finally {
147
+ const proc = browser.process();
148
+ await browser.close().catch(() => {});
149
+ if (proc && proc.exitCode == null) {
150
+ await new Promise((resolve) => {
151
+ const timer = setTimeout(resolve, 5000);
152
+ proc.once("exit", () => {
153
+ clearTimeout(timer);
154
+ resolve();
155
+ });
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Capture a deck locally with the shipped engine. Returns a serializable
163
+ * CapturedDeck (every image byte inlined) ready to POST to `/translate`.
164
+ * Throws if the engine is missing/corrupt or the local browser can't launch —
165
+ * the caller decides whether to fall back to the server render path.
166
+ */
167
+ export async function captureDeckLocally({ deckDir, bundleDir, onProgress }) {
168
+ const enginePath = path.join(bundleDir, "capture-engine.mjs");
169
+ if (!fs.existsSync(enginePath)) {
170
+ throw new Error(`no capture engine in bundle ${bundleDir}`);
171
+ }
172
+ const meta = readBundleMeta(bundleDir);
173
+ const version = meta.captureEngineVersion ?? "0";
174
+ // `?v=` busts Node's module cache so a long-lived process (the MCP server)
175
+ // picks up a refreshed engine at the same path after a bundle sync.
176
+ const engine = await import(
177
+ `${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
178
+ );
179
+ if (typeof engine.captureDeck !== "function") {
180
+ throw new Error(
181
+ `cached capture engine is corrupt (no captureDeck export) — re-run 'slidemind download-bundle'`,
182
+ );
183
+ }
184
+
185
+ const input = buildCaptureInput(deckDir, bundleDir);
186
+ return withBrowser((browser) => engine.captureDeck(browser, input, { onProgress }));
187
+ }