slidemind 0.1.5 → 0.1.7

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.
@@ -1,12 +1,18 @@
1
1
  // Render a deck server-side via the bridge, then push the bytes into a running
2
- // PowerPoint window (or write them to a file).
2
+ // PowerPoint window (or write them to a file). Also hosts `viewDraft` — the
3
+ // push-free, .pptx-free draft preview (the CLI analog of the persona's
4
+ // `see_draft`): it runs the shipped capture engine locally and returns the
5
+ // per-slide preview PNGs it already produces, so the agent can look before the
6
+ // `review` gate.
3
7
  //
4
8
  // The CLI ships ZERO exporter code: the authored slide JSX + deck-local assets
5
- // are POSTed to the add-in's `render-pptx` route, which renders them against
6
- // the presentation's own corporate design using the SAME OOXML exporter the
7
- // in-app design persona uses. The returned base64 .pptx is then inserted /
8
- // replaced (push) or written to disk (build-pptx). An open PowerPoint host with
9
- // an active corporate design is therefore a hard precondition for both paths.
9
+ // are POSTed to the add-in's render routes, which render them against the
10
+ // deck's corporate design using the SAME OOXML exporter the in-app design
11
+ // persona uses. The returned base64 .pptx is then inserted / replaced (push)
12
+ // or written to disk (build-pptx). A BOUND deck renders pane-free over the
13
+ // brand routes (PowerPoint backgrounded or closed); only the INSERT half of a
14
+ // push, and unbound decks (whose brandId must come from the pane registry),
15
+ // still need a registered PowerPoint window.
10
16
 
11
17
  import fs from "node:fs";
12
18
  import path from "node:path";
@@ -18,6 +24,7 @@ import {
18
24
  renderPptx,
19
25
  replaceSlides,
20
26
  requireHost,
27
+ requireRenderHost,
21
28
  translateDeck,
22
29
  waitForPaneReady,
23
30
  } from "./client.mjs";
@@ -89,8 +96,12 @@ function gatherDeckAssets(deckDir) {
89
96
  // ref rewritten, and the render retried once against the current design.
90
97
  export async function renderDeckToBase64(
91
98
  deckDir,
92
- { presId, backendURL = null, onProgress = null },
99
+ { presId = null, brandId = null, backendURL = null, onProgress = null },
93
100
  ) {
101
+ if (!presId && !brandId) {
102
+ throw new Error("renderDeckToBase64: presId or brandId is required");
103
+ }
104
+ const deckName = path.basename(deckDir);
94
105
  const { slides, nativeSlides, orderWarning } = gatherSlides(deckDir);
95
106
  const hasJsx = Object.keys(slides).length > 0;
96
107
  const deckAssets = gatherDeckAssets(deckDir);
@@ -99,9 +110,10 @@ export async function renderDeckToBase64(
99
110
  // silently no-ops), the local HTML/editor preview can't resolve the deck's
100
111
  // design files, AND local capture has no engine/bundle to read. `download-
101
112
  // bundle <deck>` normally writes this; here we self-heal so `push`/`build-pptx`
102
- // work even when it wasn't run explicitly.
113
+ // work even when it wasn't run explicitly. (The brand path implies a bound
114
+ // deck — its brandId came from the ref — so this only fires with a presId.)
103
115
  let ref = readDeckBundleRef(deckDir);
104
- if (!ref?.etag) {
116
+ if (!ref?.etag && presId) {
105
117
  try {
106
118
  const mat = await materializeBundle(presId, { backendURL });
107
119
  writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
@@ -125,6 +137,7 @@ export async function renderDeckToBase64(
125
137
  // .pptx slides ride alongside on either path so the server splices them by
126
138
  // play-order filename.
127
139
  const attemptRender = async (etag) => {
140
+ const target = { presId, brandId, deckName, backendURL };
128
141
  if (useLocalCapture) {
129
142
  let captured;
130
143
  try {
@@ -135,13 +148,13 @@ export async function renderDeckToBase64(
135
148
  "falling back to the server render\n",
136
149
  );
137
150
  useLocalCapture = false;
138
- return renderPptx({ presId, slides, deckAssets, nativeSlides, etag, backendURL, onProgress });
151
+ return renderPptx({ ...target, slides, deckAssets, nativeSlides, etag, onProgress });
139
152
  }
140
153
  const n = Array.isArray(captured?.slides) ? captured.slides.length : 0;
141
154
  onProgress?.({ done: n, total: n, stage: "assembling" });
142
- return translateDeck({ presId, captured, nativeSlides, etag, backendURL });
155
+ return translateDeck({ ...target, captured, nativeSlides, etag });
143
156
  }
144
- return renderPptx({ presId, slides, deckAssets, nativeSlides, etag, backendURL, onProgress });
157
+ return renderPptx({ ...target, slides, deckAssets, nativeSlides, etag, onProgress });
145
158
  };
146
159
 
147
160
  let result;
@@ -153,15 +166,18 @@ export async function renderDeckToBase64(
153
166
  // design truth lives in the add-in, so the right move is forward: refresh
154
167
  // the local bundle (so previews/lint/capture-engine match), re-bind the
155
168
  // deck, and render once against the CURRENT design. A second stale (CD
156
- // changed again mid-flight) propagates.
169
+ // changed again mid-flight) propagates. The refresh rides the SAME target
170
+ // the render used — the brand route stays pane-free end to end.
157
171
  process.stderr.write(
158
172
  "corporate design changed since this deck was authored — refreshing the " +
159
173
  "bundle and retrying the render against the current design…\n",
160
174
  );
161
- const mat = await materializeBundle(presId, {
162
- backendURL,
163
- preferBrandId: ref?.presFP ?? null,
164
- });
175
+ const mat = brandId
176
+ ? await materializeBundle(null, { backendURL, brandId })
177
+ : await materializeBundle(presId, {
178
+ backendURL,
179
+ preferBrandId: ref?.presFP ?? null,
180
+ });
165
181
  writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
166
182
  // The bundle dir is keyed by presFP, so a refreshed capture engine landed
167
183
  // in place — re-evaluate in case it appeared (or vanished) on this sync.
@@ -177,6 +193,220 @@ export async function renderDeckToBase64(
177
193
  return result; // { ok, base64, slideCount, warnings, skippedSlides }
178
194
  }
179
195
 
196
+ // Map ONE captured slide to a draft-view entry. The capture engine produces a
197
+ // `previewDataUrl` (the 1280×720 stage PNG) only for FREEFORM slides that go
198
+ // through the Chrome box capture — purpose-built (Mode A) layouts have no
199
+ // freeform region and so carry no preview, exactly like the add-in's see_draft.
200
+ // Degraded renders still yield a flat raster the agent can look at; lint/parse
201
+ // failures surface as a status line so the agent is never blind.
202
+ function mapCapturedSlide(fileName, cap, warnings) {
203
+ if (!cap) {
204
+ return {
205
+ fileName,
206
+ status: "missing",
207
+ note: "the capture engine produced no result for this slide",
208
+ };
209
+ }
210
+ for (const w of cap.lint?.warnLines ?? []) warnings.push(`${fileName}: ${w}`);
211
+ for (const w of cap.render?.warnings ?? []) warnings.push(`${fileName}: ${w}`);
212
+
213
+ if (cap.previewDataUrl) {
214
+ return { fileName, status: "ok", dataUri: cap.previewDataUrl };
215
+ }
216
+ // A degraded render is a non-editable raster; push/build-pptx will REJECT it.
217
+ // Attach it anyway (so the agent isn't blind) but flag the named cause.
218
+ if (cap.render?.degraded && cap.render?.fallbackPngBase64) {
219
+ return {
220
+ fileName,
221
+ status: "degraded",
222
+ dataUri: `data:image/png;base64,${cap.render.fallbackPngBase64}`,
223
+ note:
224
+ `did NOT render cleanly (degraded ${cap.render.degraded} raster, not editable shapes) — ` +
225
+ `push/build-pptx will reject it as-is. Fix the named cause in the JSX: ` +
226
+ (cap.render.errorText ?? "unknown render failure"),
227
+ };
228
+ }
229
+ if (cap.lint?.errorMessage) {
230
+ return { fileName, status: "failed", note: cap.lint.errorMessage };
231
+ }
232
+ if (cap.captureError) {
233
+ return { fileName, status: "failed", note: cap.captureError };
234
+ }
235
+ // No preview and no error: a purpose-built (Mode A) layout has no freeform
236
+ // region to capture — there is simply nothing to draft-preview for it.
237
+ return {
238
+ fileName,
239
+ status: "no-preview",
240
+ note: "no preview rendered — a purpose-built layout has no freeform region to capture (use slidemind review for a full-deck look)",
241
+ };
242
+ }
243
+
244
+ // Render a deck's authored slides to per-slide preview PNGs WITHOUT pushing,
245
+ // translating, or assembling a .pptx — the CLI analog of the add-in persona's
246
+ // `see_draft`. It runs the add-in's shipped capture engine on THIS machine's
247
+ // Chrome (the same engine the local-capture render path uses) and returns the
248
+ // `previewDataUrl` it already produces, so the agent can LOOK at its slides and
249
+ // self-correct before the user ever sees them at the `review` gate.
250
+ //
251
+ // Host-free once the bundle is materialized: the capture runs locally against
252
+ // the cached bundle, so no open PowerPoint is needed. A host is required ONLY
253
+ // when the bundle (and its capture engine) isn't cached yet — then we fetch it
254
+ // like `download-bundle` does. Returns { slides: [{fileName, dataUri?, status,
255
+ // note?}], warnings: [] }.
256
+ export async function viewDraft(
257
+ deckDir,
258
+ { presentationId = null, onProgress = null } = {},
259
+ ) {
260
+ if (!deckDir) throw new Error("viewDraft: deckDir is required");
261
+ const slidesDir = path.join(deckDir, "slides");
262
+ if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
263
+ throw new Error(`Expected 'slides/' folder in ${deckDir}`);
264
+ }
265
+ // Authored JSX + reused native .pptx share one play-order namespace, exactly
266
+ // as the render path sees them — walk them in that order so the draft view
267
+ // lines up with the deck the user will get.
268
+ const all = sortSlideFilenames(
269
+ fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx")),
270
+ );
271
+ if (all.length === 0) {
272
+ throw new Error(`No .jsx or .pptx slides under ${slidesDir}`);
273
+ }
274
+ const hasJsx = all.some((f) => f.endsWith(".jsx"));
275
+
276
+ // Resolve the materialized bundle. Local capture needs the bundle's capture
277
+ // engine; once it's cached this whole call is offline. Only reach for a host
278
+ // (to fetch the bundle) when there are JSX slides and no usable cached engine.
279
+ let ref = readDeckBundleRef(deckDir);
280
+ let bundleDir = resolveBundleDirForDeck(deckDir);
281
+ if (hasJsx && (!bundleDir || !bundleHasCaptureEngine(bundleDir))) {
282
+ const registry = await requireRenderHost();
283
+ const entry = pickPresentationEntry(registry, presentationId, {
284
+ preferBrandId: ref?.presFP ?? null,
285
+ });
286
+ const mat = await materializeBundle(entry.presId, {
287
+ backendURL: entry.backendURL ?? null,
288
+ preferBrandId: ref?.presFP ?? null,
289
+ });
290
+ writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
291
+ bundleDir = resolveBundleDirForDeck(deckDir);
292
+ }
293
+
294
+ const warnings = [];
295
+
296
+ // A native-only deck (just reused .pptx) has no authored JSX to draft-preview.
297
+ if (!hasJsx) {
298
+ return {
299
+ slides: all.map((fileName) => ({
300
+ fileName,
301
+ status: "native",
302
+ note: "reused native slide — not authored JSX, no draft preview rendered",
303
+ })),
304
+ warnings,
305
+ };
306
+ }
307
+
308
+ if (!bundleDir || !bundleHasCaptureEngine(bundleDir)) {
309
+ throw new Error(
310
+ "draft preview needs the corporate design's capture engine, which ships in the " +
311
+ "materialized bundle. Run `slidemind download-bundle <deck>` against an open " +
312
+ "PowerPoint host once, then retry — or use `slidemind build <deck>` for a " +
313
+ "host-free HTML preview.",
314
+ );
315
+ }
316
+
317
+ let captured;
318
+ try {
319
+ captured = await captureDeckLocally({ deckDir, bundleDir, onProgress });
320
+ } catch (err) {
321
+ throw new Error(
322
+ `local draft capture failed (${err instanceof Error ? err.message : String(err)}). ` +
323
+ "It runs a local headless Chrome; if Chrome can't launch here, use " +
324
+ "`slidemind build <deck>` for an HTML preview instead.",
325
+ );
326
+ }
327
+
328
+ const byFile = new Map((captured?.slides ?? []).map((s) => [s.fileName, s]));
329
+ const slides = all.map((fileName) =>
330
+ fileName.endsWith(".pptx")
331
+ ? {
332
+ fileName,
333
+ status: "native",
334
+ note: "reused native slide — not authored JSX, no draft preview rendered",
335
+ }
336
+ : mapCapturedSlide(fileName, byFile.get(fileName), warnings),
337
+ );
338
+
339
+ return { slides, warnings };
340
+ }
341
+
342
+ // A brand-route miss is definitive for the pane-free path but NOT for the
343
+ // deck: old add-in (no brandRoutes), legacy non-brand presFP in the ref,
344
+ // grant/tenant miss, or a brand with no bundle — every one of these still
345
+ // works through the pane flow, which needs no grant and re-binds the ref.
346
+ function isBrandRouteMiss(err) {
347
+ return (
348
+ err?.code === "brand_route_unavailable" ||
349
+ err?.code === "unknown_brand" ||
350
+ err?.code === "no_active_cd" ||
351
+ err?.status === 404
352
+ );
353
+ }
354
+
355
+ // Pane-free render for a BOUND deck: sync the bundle over the brand route
356
+ // (proves the backend + grant and refreshes the capture engine/etag exactly
357
+ // like `ready`), re-bind, then render by brandId — no PowerPoint window
358
+ // involved. Returns null when the brand route can't serve this deck (caller
359
+ // falls back to the pane flow); render failures (lint, stale-after-retry)
360
+ // propagate, they are real.
361
+ async function tryDetachedRender(deckDir, deckRef, onProgress) {
362
+ const brandId = deckRef.presFP;
363
+ let backendURL;
364
+ try {
365
+ const mat = await materializeBundle(null, { brandId });
366
+ writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
367
+ backendURL = mat.backendURL ?? null;
368
+ if (!backendURL) return null;
369
+ } catch (err) {
370
+ if (isBrandRouteMiss(err)) return null;
371
+ throw err;
372
+ }
373
+ const result = await renderDeckToBase64(deckDir, {
374
+ brandId,
375
+ backendURL,
376
+ onProgress,
377
+ });
378
+ return { result, backendURL };
379
+ }
380
+
381
+ async function renderWithRetarget(deckDir, entry, onProgress) {
382
+ const run = (target) =>
383
+ renderDeckToBase64(deckDir, {
384
+ presId: target.presId,
385
+ backendURL: target.backendURL ?? null,
386
+ onProgress,
387
+ });
388
+ try {
389
+ return { entry, result: await run(entry) };
390
+ } catch (err) {
391
+ const recoverable =
392
+ err?.status === 404 || err?.code === "unknown_presentation";
393
+ if (!recoverable) throw err;
394
+ const live = await waitForPaneReady(entry.presId, {
395
+ backendURL: entry.backendURL ?? null,
396
+ fileName: entry.fileName ?? entry.title ?? null,
397
+ brandId: entry.brandId ?? null,
398
+ timeoutMs: 15000,
399
+ onWait: (seconds) =>
400
+ process.stderr.write(
401
+ "render target changed — waiting for the slidemind pane to " +
402
+ `re-register (up to ${seconds}s)…\n`,
403
+ ),
404
+ });
405
+ if (!live) throw err;
406
+ return { entry: live, result: await run(live) };
407
+ }
408
+ }
409
+
180
410
  export async function buildAndPush({
181
411
  deckDir,
182
412
  presentationId = null,
@@ -200,23 +430,66 @@ export async function buildAndPush({
200
430
  }
201
431
 
202
432
  const deckRef = readDeckBundleRef(deckDir);
203
- const registry = await requireHost();
204
- const entry = pickPresentationEntry(registry, presentationId, {
205
- preferBrandId: deckRef?.presFP ?? null,
206
- });
207
- const presId = entry.presId;
208
- const backendURL = entry.backendURL ?? null;
209
433
 
210
- const result = await renderDeckToBase64(deckDir, {
211
- presId,
212
- backendURL,
213
- onProgress,
214
- });
434
+ // --- Render half. Pane-free for a bound deck (the window may be closed or
435
+ // backgrounded during the render); the pane matters only for the insert. ---
436
+ let result = null;
437
+ let renderEntry = null;
438
+ if (!presentationId && deckRef?.presFP) {
439
+ const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
440
+ if (detached) result = detached.result;
441
+ }
442
+ if (!result) {
443
+ const registry = await requireRenderHost();
444
+ const entry = pickPresentationEntry(registry, presentationId, {
445
+ preferBrandId: deckRef?.presFP ?? null,
446
+ });
447
+ const rendered = await renderWithRetarget(deckDir, entry, onProgress);
448
+ renderEntry = rendered.entry;
449
+ result = rendered.result;
450
+ }
451
+
215
452
  const base64 = result.base64;
216
453
  const bytes = Buffer.byteLength(base64, "base64");
217
454
  const mode = replace ? "replace" : "insert";
218
455
  const operationId = `push-${randomUUID()}`;
219
456
 
457
+ // The render survived without a window; don't lose it when the insert can't
458
+ // find one — save the .pptx next to the deck and tell the caller (bin prints
459
+ // the recovery instructions).
460
+ const saveFallback = (presIdForResult) => {
461
+ const fallbackFile = path.join(deckDir, `${path.basename(deckDir)}.pptx`);
462
+ fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
463
+ return {
464
+ presId: presIdForResult,
465
+ mode,
466
+ bytes,
467
+ operationId,
468
+ response: null,
469
+ warnings: result.warnings ?? [],
470
+ skippedSlides: result.skippedSlides ?? [],
471
+ paneSuspended: true,
472
+ fallbackFile,
473
+ };
474
+ };
475
+
476
+ // --- Insert half: needs a registered window. On the pane-free render path
477
+ // none was resolved yet; if none exists at all, the deck is saved instead of
478
+ // thrown away. ---
479
+ if (!renderEntry) {
480
+ let registry;
481
+ try {
482
+ registry = await requireHost();
483
+ } catch {
484
+ return saveFallback(null);
485
+ }
486
+ renderEntry = pickPresentationEntry(registry, presentationId, {
487
+ preferBrandId: deckRef?.presFP ?? null,
488
+ });
489
+ }
490
+ const presId = renderEntry.presId;
491
+ const backendURL = renderEntry.backendURL ?? null;
492
+
220
493
  // The SAME operationId on every attempt: the pane keeps an LRU of applied
221
494
  // operationIds and drops duplicates, so a retry after an ambiguous failure
222
495
  // can never double-insert.
@@ -242,21 +515,30 @@ export async function buildAndPush({
242
515
  try {
243
516
  resp = await doPush(presId, backendURL);
244
517
  } catch (err) {
245
- // Two recoverable shapes, both meaning "nothing was delivered":
518
+ // Three recoverable shapes:
246
519
  // - pane_suspended (503): the server waited ~25s for a reconnect and gave
247
- // up — the pane needs the user (foreground PowerPoint / click the pane).
520
+ // up — nothing was delivered; the pane needs the user (foreground
521
+ // PowerPoint / click the pane).
522
+ // - pane_timeout (504): the pane looked connected but never acked within
523
+ // the server's 60s window (just-frozen webview). Delivery is ambiguous,
524
+ // but the retry below re-sends the SAME operationId, so the pane-side
525
+ // dedup LRU makes it safe either way. The server marked the pane
526
+ // suspended on the way out, so the same client-side wake wait applies.
248
527
  // - 404: the presId vanished from the registry — a pane reconnect mints a
249
528
  // NEW presId for the same window (presId churn).
250
529
  // Wait client-side for the pane (rematching by fileName+brandId covers the
251
- // churn), then retry ONCE. A `bridge_timeout` is NOT caught: the insert
252
- // may already have been delivered, and the operationId dedup only helps if
253
- // we re-send — which we deliberately leave to the user for that case.
254
- const recoverable = err?.code === "pane_suspended" || err?.status === 404;
530
+ // churn), then retry ONCE. A client-side `bridge_timeout` is NOT caught:
531
+ // there the server-side outcome is entirely unknown (the request may still
532
+ // be executing), so a re-send is deliberately left to the user.
533
+ const recoverable =
534
+ err?.code === "pane_suspended" ||
535
+ err?.code === "pane_timeout" ||
536
+ err?.status === 404;
255
537
  if (!recoverable) throw err;
256
538
  const live = await waitForPaneReady(presId, {
257
539
  backendURL,
258
- fileName: entry.fileName ?? entry.title ?? null,
259
- brandId: entry.brandId ?? null,
540
+ fileName: renderEntry.fileName ?? renderEntry.title ?? null,
541
+ brandId: renderEntry.brandId ?? null,
260
542
  onWait: (seconds) =>
261
543
  process.stderr.write(
262
544
  "task pane suspended — click the slidemind pane in PowerPoint to " +
@@ -264,25 +546,11 @@ export async function buildAndPush({
264
546
  ),
265
547
  });
266
548
  if (!live) {
267
- if (err?.code !== "pane_suspended") throw err;
268
- // The pane never woke up. Don't lose the render: save the .pptx next to
269
- // the deck and tell the caller (bin prints the recovery instructions).
270
- const fallbackFile = path.join(
271
- deckDir,
272
- `${path.basename(deckDir)}.pptx`,
273
- );
274
- fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
275
- return {
276
- presId,
277
- mode,
278
- bytes,
279
- operationId,
280
- response: null,
281
- warnings: result.warnings ?? [],
282
- skippedSlides: result.skippedSlides ?? [],
283
- paneSuspended: true,
284
- fallbackFile,
285
- };
549
+ if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
550
+ throw err;
551
+ }
552
+ // The pane never woke up — same recovery as no window at all.
553
+ return saveFallback(presId);
286
554
  }
287
555
  livePresId = live.presId;
288
556
  resp = await doPush(live.presId, live.backendURL ?? backendURL);
@@ -298,9 +566,11 @@ export async function buildAndPush({
298
566
  };
299
567
  }
300
568
 
301
- // Render a deck to a .pptx file (the `slidemind build-pptx` path). Still
302
- // requires an open host because the render happens server-side — but NOT an
303
- // awake task pane: the render runs in the add-in's cloud backend.
569
+ // Render a deck to a .pptx file (the `slidemind build-pptx` path). For a BOUND
570
+ // deck against a backend serving brand routes this is fully pane-free — no
571
+ // PowerPoint window needed at all (backgrounded or closed). Otherwise it needs
572
+ // an open host because the brandId must come from the pane registry — but
573
+ // still not an awake pane: the render runs in the add-in's cloud backend.
304
574
  export async function buildPptxFile({
305
575
  deckDir,
306
576
  presentationId = null,
@@ -309,18 +579,35 @@ export async function buildPptxFile({
309
579
  }) {
310
580
  if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
311
581
  const deckRef = readDeckBundleRef(deckDir);
312
- const registry = await requireHost();
582
+ const out = outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
583
+
584
+ // Pane-free path. `--to <presId>` names a window explicitly and therefore
585
+ // keeps the registry path.
586
+ if (!presentationId && deckRef?.presFP) {
587
+ const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
588
+ if (detached) {
589
+ fs.writeFileSync(out, Buffer.from(detached.result.base64, "base64"));
590
+ return {
591
+ outPath: out,
592
+ presId: null,
593
+ windowless: true,
594
+ slideCount: detached.result.slideCount ?? null,
595
+ warnings: detached.result.warnings ?? [],
596
+ skippedSlides: detached.result.skippedSlides ?? [],
597
+ };
598
+ }
599
+ }
600
+
601
+ const registry = await requireRenderHost();
313
602
  const entry = pickPresentationEntry(registry, presentationId, {
314
603
  preferBrandId: deckRef?.presFP ?? null,
315
604
  });
316
- const presId = entry.presId;
317
- const result = await renderDeckToBase64(deckDir, {
318
- presId,
319
- backendURL: entry.backendURL ?? null,
605
+ const { entry: renderEntry, result } = await renderWithRetarget(
606
+ deckDir,
607
+ entry,
320
608
  onProgress,
321
- });
322
- const out =
323
- outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
609
+ );
610
+ const presId = renderEntry.presId;
324
611
  fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
325
612
  return {
326
613
  outPath: out,
@@ -45,7 +45,11 @@ import path from "node:path";
45
45
  import http from "node:http";
46
46
  import https from "node:https";
47
47
 
48
- import { getDesignBundle } from "./client.mjs";
48
+ import {
49
+ getDesignBundle,
50
+ getDesignBundleForBrand,
51
+ getDesignBundleForBrandAnyBackend,
52
+ } from "./client.mjs";
49
53
 
50
54
  // Highest bundle wire-contract version this CLI understands. The add-in stamps
51
55
  // `schemaVersion` on every bundle; a higher value means the CLI is too old.
@@ -65,6 +69,21 @@ export function bundleDirForPresFP(presFP) {
65
69
  return path.join(bundleCacheRoot(), sanitizeKey(presFP));
66
70
  }
67
71
 
72
+ // Zero-GET lookup of a materialized bundle's meta by presFP: reads the on-disk
73
+ // bundle.json materializeBundle wrote and returns the parsed record, or null
74
+ // when that CD was never materialized here / the file is unreadable. `new`'s
75
+ // stateless auto-bind uses this to confirm a resolved design's bundle already
76
+ // exists locally BEFORE writing a deck ref — no network, no throw.
77
+ export function readMaterializedBundleMeta(presFP) {
78
+ if (!presFP) return null;
79
+ try {
80
+ const metaPath = path.join(bundleDirForPresFP(presFP), "bundle.json");
81
+ return JSON.parse(fs.readFileSync(metaPath, "utf8"));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
68
87
  // Wipe the whole materialized bundle cache so the next build/push/download
69
88
  // re-fetches every design bundle fresh from the add-in. `slidemind init` calls
70
89
  // this: init is the deliberate (re)setup point — and `slidemind update`
@@ -220,13 +239,31 @@ function buildReusableImagesManifest(extractedImages) {
220
239
  // a bundle re-produce (engines, image-source guidelines, sample curation) and
221
240
  // deployment-owned content (workflow refs, the lint engine) can never go
222
241
  // stale here. `force` re-downloads binaries even when their etags match.
223
- // Returns { dir, presFP, etag, version, downloaded, pruned, engineUpdated,
224
- // warnings }.
242
+ // Target: `presId` (pane registry), or `brandId` in options for the pane-free
243
+ // brand route (presId may then be null; backendURL null = fan-out).
244
+ // Returns { dir, presFP, etag, version, backendURL, downloaded, pruned,
245
+ // engineUpdated, warnings }.
225
246
  export async function materializeBundle(
226
247
  presId,
227
- { force = false, backendURL = null, preferBrandId = null } = {},
248
+ { force = false, backendURL = null, preferBrandId = null, brandId = null } = {},
228
249
  ) {
229
- const wire = await getDesignBundle(presId, { backendURL, preferBrandId });
250
+ // Two fetch paths for the same wire: by presId through the pane registry
251
+ // (needs a registered window), or pane-free by brandId through the brand
252
+ // route (bound deck, PowerPoint optional). Everything below operates on the
253
+ // wire and is identical for both.
254
+ let wire;
255
+ let wireBackendURL = backendURL;
256
+ if (brandId) {
257
+ if (backendURL) {
258
+ wire = await getDesignBundleForBrand(brandId, { backendURL });
259
+ } else {
260
+ const resolved = await getDesignBundleForBrandAnyBackend(brandId);
261
+ wire = resolved.wire;
262
+ wireBackendURL = resolved.backendURL;
263
+ }
264
+ } else {
265
+ wire = await getDesignBundle(presId, { backendURL, preferBrandId });
266
+ }
230
267
  if (!wire || !wire.presFP) {
231
268
  throw new Error("design-bundle response is missing presFP");
232
269
  }
@@ -445,6 +482,9 @@ export async function materializeBundle(
445
482
 
446
483
  const meta = {
447
484
  presFP,
485
+ // In the brand model presFP == brandId; recorded explicitly so consumers
486
+ // (and humans reading the cache) don't need to know that equivalence.
487
+ brandId: presFP,
448
488
  version: wire.version ?? null,
449
489
  etag: wire.etag ?? null,
450
490
  scope: wire.scope ?? null,
@@ -483,6 +523,9 @@ export async function materializeBundle(
483
523
  presFP,
484
524
  etag: wire.etag ?? null,
485
525
  version: wire.version ?? null,
526
+ // Which backend actually served the wire — meaningful on the brandId
527
+ // fan-out path, where the caller didn't know it up front.
528
+ backendURL: wireBackendURL,
486
529
  downloaded,
487
530
  pruned,
488
531
  engineUpdated,
@@ -613,8 +656,7 @@ export function isImageEngine(engine) {
613
656
  // `kind`. Returns null when the CD exposes no image engine (e.g. only a
614
657
  // slide-search engine) — callers surface a "wire an image engine" message
615
658
  // rather than falling back to an engine the bridge can't insert. Single source
616
- // of truth for the heuristic shared by the CLI dispatcher (`slidemind search`)
617
- // and the MCP `slidemind_search_engine` tool.
659
+ // of truth for the heuristic used by the CLI dispatcher (`slidemind search`).
618
660
  export function pickImageEngine(engines) {
619
661
  const list = Array.isArray(engines) ? engines : [];
620
662
  return list.find(isImageEngine) ?? null;
@@ -171,8 +171,8 @@ export async function captureDeckLocally({ deckDir, bundleDir, onProgress }) {
171
171
  }
172
172
  const meta = readBundleMeta(bundleDir);
173
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.
174
+ // `?v=` busts Node's module cache so a re-import picks up a refreshed engine
175
+ // at the same path after a bundle sync.
176
176
  const engine = await import(
177
177
  `${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
178
178
  );