@bendyline/squisq-cli 2.5.5 → 2.6.0

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.
package/README.md CHANGED
@@ -96,9 +96,45 @@ Notes:
96
96
  - **An existing output file is never overwritten by default.** The check runs before rendering, so a colliding path costs you a second rather than a full capture. Pass `--overwrite` to replace.
97
97
  - FFmpeg failures report the one relevant line (e.g. `[libx264] width not divisible by 2 (851x480) (exit code 1)`) rather than dumping the whole command line and stderr buffer.
98
98
 
99
+ ### `squisq image <input> [output]`
100
+
101
+ Render a document's **Dashboard** rendition — every block arranged on one
102
+ canvas — to a PNG image. Needs Playwright Chromium only (no ffmpeg; this is a
103
+ single-frame capture, not a video encode). The layout comes from the doc's
104
+ `squisq-dashboard-layout` frontmatter (or `--layout`), defaulting to the
105
+ auto-pick that chooses the smallest built-in layout fitting the block count;
106
+ the document-title band renders by default and `--no-title` hides it. `--style`
107
+ picks the cell dressing (`basic`, `card`, `panel`, `accent` — all theme-derived),
108
+ defaulting to the doc's `squisq-dashboard-style`.
109
+
110
+ ```bash
111
+ squisq image input.md output.png
112
+ squisq image input.md --resolution 4k
113
+ squisq image input.md --resolution square --layout grid-2x2
114
+ squisq image input.md --style card
115
+ squisq image input.md --width 1280 --height 720 --no-title
116
+ squisq image input.md -t documentary --transform magazine
117
+ ```
118
+
119
+ | Option | Description | Default |
120
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------- |
121
+ | `-o, --output` | Output `.png` path | `<input>.png` |
122
+ | `--resolution` | Named preset: `hd`, `fhd`, `4k`, `square`, `square-2k`, `portrait`, `portrait-4k`, `standard` | `fhd` |
123
+ | `--width` / `--height` | Custom pixels (both required together; excludes `--resolution`). 64–7680 per axis, ≤33 MP; odd values fine | preset |
124
+ | `--layout` | Dashboard layout id (built-ins or a doc-defined custom layout), or `auto` | `auto` |
125
+ | `--style` | Cell style variant: `basic`, `card`, `panel`, `accent` (all theme-derived) | doc setting |
126
+ | `--title` / `--no-title` | Show/hide the document-title band | shown |
127
+ | `--format` | Output format (`png`) | `png` |
128
+ | `-t, --theme` | Squisq theme id to apply | none |
129
+ | `--transform` | Transform style to apply before rendering | none |
130
+ | `--overwrite` | Replace an existing output file (otherwise refuse and exit non-zero) | off |
131
+ | `--no-auto-templates` | Disable content-aware template auto-picking for unannotated headings | (auto on) |
132
+
133
+ The same export is available through the registry as `squisq convert <input> -f png`.
134
+
99
135
  ### `squisq doctor`
100
136
 
101
- Preflight the video toolchain: reports the resolved ffmpeg path, version, and which source it came from (`SQUISQ_FFMPEG` env / `PATH` / `ffmpeg-static`) with an install hint when missing, attempts a headless Chromium launch, and reports the Node version.
137
+ Preflight the rendering toolchain: reports the resolved ffmpeg path, version, and which source it came from (`SQUISQ_FFMPEG` env / `PATH` / `ffmpeg-static`) with an install hint when missing, attempts a headless Chromium launch, reports the Node version, and summarizes per-feature readiness (video/GIF need ffmpeg + Chromium; the dashboard PNG image needs Chromium only).
102
138
 
103
139
  ```bash
104
140
  squisq doctor
@@ -222,6 +258,27 @@ captions, an infinite loop, and disabled slide animations/transitions. Set
222
258
  `captionStyle: 'off'` to omit captions. Embedded video still advances, but all
223
259
  audio is omitted and reported through `result.warnings`.
224
260
 
261
+ ### `renderDocToDashboardPng`
262
+
263
+ ```ts
264
+ import { renderDocToDashboardPng } from '@bendyline/squisq-cli/api';
265
+
266
+ const result = await renderDocToDashboardPng(doc, input.container, {
267
+ outputPath: './dashboard.png',
268
+ resolution: 'square', // or width/height for custom pixels
269
+ layout: 'grid-2x2', // 'auto' (default) picks by block count
270
+ style: 'card', // 'basic' (default) | 'card' | 'panel' | 'accent'
271
+ title: true,
272
+ });
273
+ // result: { bytes: Uint8Array, width, height, outputPath? }
274
+ ```
275
+
276
+ Renders the Dashboard rendition to a single PNG. Needs Playwright Chromium
277
+ only — no ffmpeg. `resolution` names a preset from `DASHBOARD_RESOLUTIONS`
278
+ (default `fhd`, 1920×1080); explicit `width`/`height` (both together) replace
279
+ it. Apply a theme upstream (`{ ...doc, themeId }`), the same convention the
280
+ video renderers use.
281
+
225
282
  ### Native frame encoding
226
283
 
227
284
  For callers that already have PNG frames and do not need Playwright capture,
@@ -271,6 +328,7 @@ await extractThumbnails({
271
328
  - `readInput(inputPath)` → `{ doc: Doc, container: ContentContainer, markdownDoc?: MarkdownDocument, sourceFormat: FormatId }`
272
329
  - `MemoryContentContainer` (re-export from `@bendyline/squisq/storage`)
273
330
  - `VideoQuality`, `VideoOrientation` types (re-exports from `@bendyline/squisq-video`)
331
+ - `DASHBOARD_RESOLUTIONS`, `DEFAULT_DASHBOARD_RESOLUTION`, `resolveDashboardDimensions`, `validateDashboardImageDimensions` (re-exports from `@bendyline/squisq-video`)
274
332
 
275
333
  See the full [API Reference](https://github.com/bendyline/squisq/blob/main/docs/API.md#bendylinesquisq-cli) for all types and options.
276
334
 
@@ -2,6 +2,8 @@
2
2
  import {
3
3
  CapturedFrameBudgetError,
4
4
  ConversionError,
5
+ DASHBOARD_RESOLUTIONS,
6
+ DEFAULT_DASHBOARD_RESOLUTION,
5
7
  MAX_CAPTURED_FRAME_BYTES,
6
8
  MemoryContentContainer,
7
9
  convert,
@@ -9,9 +11,12 @@ import {
9
11
  extractThumbnails,
10
12
  prepareConversion,
11
13
  readInput,
14
+ renderDocToDashboardPng,
12
15
  renderDocToGif,
13
- renderDocToMp4
14
- } from "./chunk-SFBZGL54.js";
16
+ renderDocToMp4,
17
+ resolveDashboardDimensions,
18
+ validateDashboardImageDimensions
19
+ } from "./chunk-2ZJW7F62.js";
15
20
  import {
16
21
  GIF_EXPORT_DEFAULTS,
17
22
  framesToGifNative,
@@ -22,6 +27,8 @@ import {
22
27
  export {
23
28
  CapturedFrameBudgetError,
24
29
  ConversionError,
30
+ DASHBOARD_RESOLUTIONS,
31
+ DEFAULT_DASHBOARD_RESOLUTION,
25
32
  GIF_EXPORT_DEFAULTS,
26
33
  MAX_CAPTURED_FRAME_BYTES,
27
34
  MemoryContentContainer,
@@ -34,6 +41,9 @@ export {
34
41
  framesToMp4NativeBytes,
35
42
  prepareConversion,
36
43
  readInput,
44
+ renderDocToDashboardPng,
37
45
  renderDocToGif,
38
- renderDocToMp4
46
+ renderDocToMp4,
47
+ resolveDashboardDimensions,
48
+ validateDashboardImageDimensions
39
49
  };
package/dist/api.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { Doc } from '@bendyline/squisq/schemas';
2
+ import { DashboardStyleId } from '@bendyline/squisq/doc';
2
3
  import { ContentContainer } from '@bendyline/squisq/storage';
3
4
  export { MemoryContentContainer } from '@bendyline/squisq/storage';
4
- import { VideoQuality, VideoOrientation, GifDither, VideoExportOptions } from '@bendyline/squisq-video';
5
- export { GifDither, VideoOrientation, VideoQuality } from '@bendyline/squisq-video';
5
+ import { VideoQuality, VideoOrientation, GifDither, DashboardResolutionId, VideoExportOptions } from '@bendyline/squisq-video';
6
+ export { DASHBOARD_RESOLUTIONS, DEFAULT_DASHBOARD_RESOLUTION, DashboardResolutionId, DashboardResolutionPreset, GifDither, VideoOrientation, VideoQuality, resolveDashboardDimensions, validateDashboardImageDimensions } from '@bendyline/squisq-video';
6
7
  import { FormatRegistry, FormatId, ConvertOptions, ConvertSource, ConversionResult, PreparedConversion } from '@bendyline/squisq-formats';
7
8
  export { ConversionError, ConversionErrorCode, ConversionErrorOptions, ConversionResult, ConvertOptions, ConvertSource, FormatDefinition, FormatId, FormatRegistry, NormalizedInput, PreparedConversion, PreparedExportOptions } from '@bendyline/squisq-formats';
8
9
  import { MarkdownDocument } from '@bendyline/squisq/markdown';
@@ -42,6 +43,22 @@ interface GifFormatOptions {
42
43
  dither?: GifDither;
43
44
  bayerScale?: number;
44
45
  }
46
+ /**
47
+ * Per-export options for `convert(..., 'png')` — the Dashboard image.
48
+ * `resolution` names a preset; explicit `width`/`height` (both required
49
+ * together) win over it. `layout`/`style`/`title` override the doc's
50
+ * dashboard frontmatter.
51
+ */
52
+ interface PngFormatOptions {
53
+ onProgress?: (phase: string, percent: number) => void;
54
+ resolution?: DashboardResolutionId;
55
+ width?: number;
56
+ height?: number;
57
+ layout?: string;
58
+ /** Cell style variant; unset defers to the doc's own frontmatter. */
59
+ style?: DashboardStyleId;
60
+ title?: boolean;
61
+ }
45
62
  /**
46
63
  * Build the CLI's format registry: every built-in format from
47
64
  * `@bendyline/squisq-formats` plus the CLI-only rendered-media exporters.
@@ -218,6 +235,7 @@ type CliConvertOptions = Omit<ConvertOptions, 'formatOptions'> & {
218
235
  formatOptions?: ConvertOptions['formatOptions'] & {
219
236
  mp4?: Mp4FormatOptions;
220
237
  gif?: GifFormatOptions;
238
+ png?: PngFormatOptions;
221
239
  };
222
240
  };
223
241
 
@@ -319,6 +337,50 @@ interface RenderDocToGifResult extends RenderDocToMp4Result {
319
337
  /** Non-fatal fidelity warnings, including GIF's lack of audio. */
320
338
  warnings: string[];
321
339
  }
340
+ /** Options for {@link renderDocToDashboardPng}. */
341
+ interface RenderDashboardPngOptions {
342
+ /** Abort the render; rejects with the signal's reason. */
343
+ signal?: AbortSignal;
344
+ /** Optional output file; bytes are always returned. */
345
+ outputPath?: string;
346
+ /** Named resolution preset (default `'fhd'`, 1920×1080). */
347
+ resolution?: DashboardResolutionId;
348
+ /** Custom pixel width; requires `height` and excludes `resolution`. */
349
+ width?: number;
350
+ /** Custom pixel height; requires `width` and excludes `resolution`. */
351
+ height?: number;
352
+ /** Dashboard layout id, or `'auto'` for the block-count pick (default). */
353
+ layout?: string;
354
+ /**
355
+ * Render the document-title band. Leave unset to defer to the doc's own
356
+ * `squisq-dashboard-title` frontmatter (which itself defaults on).
357
+ */
358
+ title?: boolean;
359
+ /**
360
+ * Cell style variant (`basic` | `card` | `panel` | `accent`). Default:
361
+ * the document's own `squisq-dashboard-style` setting.
362
+ */
363
+ style?: DashboardStyleId;
364
+ /** Host-supplied title fallback when the doc has no frontmatter title. */
365
+ documentTitle?: string;
366
+ onProgress?: (phase: string, percent: number) => void;
367
+ }
368
+ /** Result returned by {@link renderDocToDashboardPng}. */
369
+ interface RenderDashboardPngResult {
370
+ bytes: Uint8Array;
371
+ width: number;
372
+ height: number;
373
+ /** Absolute path written, when `outputPath` was requested. */
374
+ outputPath?: string;
375
+ }
376
+ /**
377
+ * Render a Doc's Dashboard rendition to a single PNG image.
378
+ *
379
+ * Needs Playwright Chromium only — unlike the video paths there is no
380
+ * ffmpeg involvement. Apply a theme upstream (`{ ...doc, themeId }`), the
381
+ * same convention `renderDocToMp4`/`renderDocToGif` use.
382
+ */
383
+ declare function renderDocToDashboardPng(doc: Doc, container: ContentContainer, options?: RenderDashboardPngOptions): Promise<RenderDashboardPngResult>;
322
384
  /** Render a Doc + media container to an MP4 video file. */
323
385
  declare function renderDocToMp4(doc: Doc, container: ContentContainer, options: RenderDocToMp4Options): Promise<RenderDocToMp4Result>;
324
386
  /** Render a Doc + media container to a silent animated GIF. */
@@ -355,4 +417,4 @@ interface ExtractThumbnailsOptions {
355
417
  */
356
418
  declare function extractThumbnails(options: ExtractThumbnailsOptions): Promise<void>;
357
419
 
358
- export { CapturedFrameBudgetError, type CliConvertOptions, type ExtractThumbnailsOptions, GIF_EXPORT_DEFAULTS, type GifExportOptions, type GifFormatOptions, MAX_CAPTURED_FRAME_BYTES, type Mp4FormatOptions, type NativeVideoExportOptions, type ReadInputResult, type RenderDocToGifOptions, type RenderDocToGifResult, type RenderDocToMp4Options, type RenderDocToMp4Result, type ThumbnailSpec, convert, createCliRegistry, extractThumbnails, framesToGifNative, framesToGifNativeBytes, framesToMp4Native, framesToMp4NativeBytes, prepareConversion, readInput, renderDocToGif, renderDocToMp4 };
420
+ export { CapturedFrameBudgetError, type CliConvertOptions, type ExtractThumbnailsOptions, GIF_EXPORT_DEFAULTS, type GifExportOptions, type GifFormatOptions, MAX_CAPTURED_FRAME_BYTES, type Mp4FormatOptions, type NativeVideoExportOptions, type PngFormatOptions, type ReadInputResult, type RenderDashboardPngOptions, type RenderDashboardPngResult, type RenderDocToGifOptions, type RenderDocToGifResult, type RenderDocToMp4Options, type RenderDocToMp4Result, type ThumbnailSpec, convert, createCliRegistry, extractThumbnails, framesToGifNative, framesToGifNativeBytes, framesToMp4Native, framesToMp4NativeBytes, prepareConversion, readInput, renderDocToDashboardPng, renderDocToGif, renderDocToMp4 };
package/dist/api.js CHANGED
@@ -8,11 +8,12 @@ import {
8
8
  } from "./chunk-Z3BHD4AF.js";
9
9
 
10
10
  // src/api.ts
11
- import { readFile as readFile3 } from "fs/promises";
11
+ import { mkdir, readFile as readFile3, writeFile } from "fs/promises";
12
+ import { dirname as dirname2, resolve as resolvePath } from "path";
12
13
  import { resolveMediaSchedule as resolveMediaSchedule2 } from "@bendyline/squisq/schemas";
13
14
  import { flattenBlocks as flattenBlocks2 } from "@bendyline/squisq/doc";
14
15
  import { ffmpegGifOutputArgs, generateRenderHtml } from "@bendyline/squisq-video";
15
- import { resolveDimensions } from "@bendyline/squisq-video";
16
+ import { resolveDashboardDimensions, resolveDimensions } from "@bendyline/squisq-video";
16
17
  import {
17
18
  convert as formatsConvert,
18
19
  prepareConversion as formatsPrepareConversion
@@ -97,13 +98,13 @@ async function buildMixedAudioTrack(doc, container, ffmpegPath, coverPreRoll, si
97
98
  return mixTimelineClips(ffmpegPath, usable, signal);
98
99
  }
99
100
  async function mixTimelineClips(ffmpegPath, usable, signal) {
100
- const { writeFile, readFile: readFile4, mkdir, rm: rm2 } = await import("fs/promises");
101
+ const { writeFile: writeFile2, readFile: readFile4, mkdir: mkdir2, rm: rm2 } = await import("fs/promises");
101
102
  const { join: join3 } = await import("path");
102
103
  const { tmpdir: tmpdir2 } = await import("os");
103
104
  const { randomBytes: randomBytes2 } = await import("crypto");
104
105
  signal?.throwIfAborted();
105
106
  const workDir = join3(tmpdir2(), `squisq-audio-mix-${randomBytes2(8).toString("hex")}`);
106
- await mkdir(workDir, { recursive: true });
107
+ await mkdir2(workDir, { recursive: true });
107
108
  const ms = (s) => Math.max(0, Math.round(s * 1e3));
108
109
  try {
109
110
  signal?.throwIfAborted();
@@ -113,7 +114,7 @@ async function mixTimelineClips(ffmpegPath, usable, signal) {
113
114
  for (const { clip, buffer } of usable) {
114
115
  signal?.throwIfAborted();
115
116
  const p = join3(workDir, `clip-${inputs.length}.mp3`);
116
- await writeFile(p, new Uint8Array(buffer));
117
+ await writeFile2(p, new Uint8Array(buffer));
117
118
  signal?.throwIfAborted();
118
119
  const i = inputs.push(p) - 1;
119
120
  const delayMs = ms(clip.startSec);
@@ -407,10 +408,38 @@ function gifFormat() {
407
408
  }
408
409
  };
409
410
  }
411
+ function pngFormat() {
412
+ return {
413
+ id: "png",
414
+ templateAnnotationHandling: "rendered",
415
+ label: "Dashboard Image",
416
+ mimeType: "image/png",
417
+ extensions: [".png"],
418
+ async exportDoc(input, options) {
419
+ options.signal?.throwIfAborted();
420
+ const pngOpts = options.formatOptions?.png ?? {};
421
+ const { renderDocToDashboardPng: renderDocToDashboardPng2 } = await import("./api.js");
422
+ const result = await renderDocToDashboardPng2(input.doc, input.container, {
423
+ resolution: pngOpts.resolution,
424
+ width: pngOpts.width,
425
+ height: pngOpts.height,
426
+ layout: pngOpts.layout,
427
+ style: pngOpts.style,
428
+ title: pngOpts.title,
429
+ documentTitle: options.title ?? input.baseName,
430
+ signal: options.signal,
431
+ onProgress: pngOpts.onProgress
432
+ });
433
+ options.signal?.throwIfAborted();
434
+ return { bytes: result.bytes, mimeType: "image/png", suggestedFilename: "", warnings: [] };
435
+ }
436
+ };
437
+ }
410
438
  function createCliRegistry() {
411
439
  const registry = defaultRegistry();
412
440
  registry.register(mp4Format());
413
441
  registry.register(gifFormat());
442
+ registry.register(pngFormat());
414
443
  return registry;
415
444
  }
416
445
 
@@ -827,6 +856,12 @@ function throwIfAborted(signal) {
827
856
  }
828
857
 
829
858
  // src/api.ts
859
+ import {
860
+ DASHBOARD_RESOLUTIONS,
861
+ DEFAULT_DASHBOARD_RESOLUTION,
862
+ resolveDashboardDimensions as resolveDashboardDimensions2,
863
+ validateDashboardImageDimensions
864
+ } from "@bendyline/squisq-video";
830
865
  import { ConversionError } from "@bendyline/squisq-formats";
831
866
  var playerBundlePromise;
832
867
  var fullPlayerBundlePromise;
@@ -866,17 +901,18 @@ async function prepareConversion(source, options = {}) {
866
901
  ...options
867
902
  });
868
903
  }
869
- async function captureDocFrames(doc, container, options) {
870
- const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress, signal } = options;
871
- signal?.throwIfAborted();
872
- resolveAppliedCoverPreRoll(coverPreRoll, true);
873
- const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
874
- signal?.throwIfAborted();
875
- if (!ffmpegPath) {
876
- throw new Error(
877
- "ffmpeg is required but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg\nOr: npm install ffmpeg-static, or set SQUISQ_FFMPEG to an ffmpeg binary."
878
- );
879
- }
904
+ async function withRenderPage(doc, container, options, fn) {
905
+ const {
906
+ signal,
907
+ width,
908
+ height,
909
+ includeAudio,
910
+ captionStyle,
911
+ animationsEnabled,
912
+ displayMode,
913
+ dashboard,
914
+ onProgress
915
+ } = options;
880
916
  onProgress?.("collecting media", 0);
881
917
  signal?.throwIfAborted();
882
918
  const { collectImagePaths } = await import("@bendyline/squisq-formats/html");
@@ -893,14 +929,16 @@ async function captureDocFrames(doc, container, options) {
893
929
  }
894
930
  }
895
931
  const audio = /* @__PURE__ */ new Map();
896
- for (const seg of doc.audio?.segments ?? []) {
897
- signal?.throwIfAborted();
898
- const data = await container.readFile(seg.src);
899
- signal?.throwIfAborted();
900
- if (data) {
901
- budget.admit(seg.src, data);
902
- audio.set(seg.src, data);
903
- audio.set(seg.name, data);
932
+ if (includeAudio) {
933
+ for (const seg of doc.audio?.segments ?? []) {
934
+ signal?.throwIfAborted();
935
+ const data = await container.readFile(seg.src);
936
+ signal?.throwIfAborted();
937
+ if (data) {
938
+ budget.admit(seg.src, data);
939
+ audio.set(seg.src, data);
940
+ audio.set(seg.name, data);
941
+ }
904
942
  }
905
943
  }
906
944
  const mediaSrcs = new Set(resolveMediaSchedule2(doc).map((clip) => clip.src));
@@ -930,7 +968,9 @@ async function captureDocFrames(doc, container, options) {
930
968
  width,
931
969
  height,
932
970
  captionStyle,
933
- animationsEnabled
971
+ animationsEnabled,
972
+ displayMode,
973
+ dashboard
934
974
  });
935
975
  onProgress?.("launching browser", 15);
936
976
  signal?.throwIfAborted();
@@ -956,7 +996,6 @@ async function captureDocFrames(doc, container, options) {
956
996
  }
957
997
  signal?.addEventListener("abort", handleAbort, { once: true });
958
998
  let renderAPI = null;
959
- const capturedFrames = new CapturedFrameCollector();
960
999
  try {
961
1000
  const page = await browser.newPage({ viewport: { width, height } });
962
1001
  const pageErrors = [];
@@ -997,58 +1036,135 @@ Page errors:
997
1036
  return api;
998
1037
  });
999
1038
  signal?.throwIfAborted();
1000
- const docDuration = await renderAPI.evaluate((api) => api.getDuration());
1001
- signal?.throwIfAborted();
1002
- if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
1003
- const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
1004
- const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
1005
- const storyFrameCount = Math.ceil(docDuration * fps);
1006
- const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
1007
- const totalFrames = preRollFrameCount + storyFrameCount;
1008
- onProgress?.("capturing frames", 20);
1009
- capturedFrames.throwIfAborted(signal);
1010
- if (preRollFrameCount > 0) {
1011
- capturedFrames.throwIfAborted(signal);
1012
- await renderAPI.evaluate((api) => api.showCover());
1013
- await page.waitForTimeout(100);
1014
- capturedFrames.throwIfAborted(signal);
1015
- const coverFrame = await page.screenshot({ type: "png" });
1016
- capturedFrames.throwIfAborted(signal);
1017
- capturedFrames.append(coverFrame, preRollFrameCount);
1018
- await renderAPI.evaluate((api) => api.hideCover());
1019
- }
1020
- const frameInterval = 1 / fps;
1021
- for (let i = 0; i < storyFrameCount; i++) {
1022
- capturedFrames.throwIfAborted(signal);
1023
- const time = i * frameInterval;
1024
- await renderAPI.evaluate((api, t) => api.seekTo(t), time);
1025
- const frame = await page.screenshot({ type: "png" });
1026
- capturedFrames.throwIfAborted(signal);
1027
- capturedFrames.append(frame);
1028
- if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
1029
- onProgress?.(
1030
- "capturing frames",
1031
- 20 + Math.round(capturedFrames.frameCount / totalFrames * 60)
1032
- );
1039
+ return await fn({ page, renderAPI });
1040
+ } finally {
1041
+ signal?.removeEventListener("abort", handleAbort);
1042
+ await renderAPI?.dispose().catch(() => void 0);
1043
+ await browser.close().catch(() => void 0);
1044
+ }
1045
+ }
1046
+ async function captureDocFrames(doc, container, options) {
1047
+ const { fps, width, height, captionStyle, coverPreRoll, animationsEnabled, onProgress, signal } = options;
1048
+ signal?.throwIfAborted();
1049
+ resolveAppliedCoverPreRoll(coverPreRoll, true);
1050
+ const ffmpegPath = (await detectFfmpegDetailed(signal))?.path ?? null;
1051
+ signal?.throwIfAborted();
1052
+ if (!ffmpegPath) {
1053
+ throw new Error(
1054
+ "ffmpeg is required but not found in PATH.\nInstall it with:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: winget install ffmpeg\nOr: npm install ffmpeg-static, or set SQUISQ_FFMPEG to an ffmpeg binary."
1055
+ );
1056
+ }
1057
+ const capturedFrames = new CapturedFrameCollector();
1058
+ try {
1059
+ return await withRenderPage(
1060
+ doc,
1061
+ container,
1062
+ { signal, width, height, includeAudio: true, captionStyle, animationsEnabled, onProgress },
1063
+ async ({ page, renderAPI }) => {
1064
+ const docDuration = await renderAPI.evaluate((api) => api.getDuration());
1065
+ signal?.throwIfAborted();
1066
+ if (docDuration <= 0) throw new Error("Document has zero duration \u2014 nothing to render");
1067
+ const hasCover = coverPreRoll > 0 ? await renderAPI.evaluate((api) => api.hasCoverBlock()) : false;
1068
+ const appliedCoverPreRoll = resolveAppliedCoverPreRoll(coverPreRoll, hasCover);
1069
+ const storyFrameCount = Math.ceil(docDuration * fps);
1070
+ const preRollFrameCount = Math.ceil(appliedCoverPreRoll * fps);
1071
+ const totalFrames = preRollFrameCount + storyFrameCount;
1072
+ onProgress?.("capturing frames", 20);
1033
1073
  capturedFrames.throwIfAborted(signal);
1074
+ if (preRollFrameCount > 0) {
1075
+ capturedFrames.throwIfAborted(signal);
1076
+ await renderAPI.evaluate((api) => api.showCover());
1077
+ await page.waitForTimeout(100);
1078
+ capturedFrames.throwIfAborted(signal);
1079
+ const coverFrame = await page.screenshot({ type: "png" });
1080
+ capturedFrames.throwIfAborted(signal);
1081
+ capturedFrames.append(coverFrame, preRollFrameCount);
1082
+ await renderAPI.evaluate((api) => api.hideCover());
1083
+ }
1084
+ const frameInterval = 1 / fps;
1085
+ for (let i = 0; i < storyFrameCount; i++) {
1086
+ capturedFrames.throwIfAborted(signal);
1087
+ const time = i * frameInterval;
1088
+ await renderAPI.evaluate((api, t) => api.seekTo(t), time);
1089
+ const frame = await page.screenshot({ type: "png" });
1090
+ capturedFrames.throwIfAborted(signal);
1091
+ capturedFrames.append(frame);
1092
+ if (i % Math.max(1, Math.floor(fps / 2)) === 0 || i === storyFrameCount - 1) {
1093
+ onProgress?.(
1094
+ "capturing frames",
1095
+ 20 + Math.round(capturedFrames.frameCount / totalFrames * 60)
1096
+ );
1097
+ capturedFrames.throwIfAborted(signal);
1098
+ }
1099
+ }
1100
+ return {
1101
+ frames: capturedFrames.release(),
1102
+ totalDuration: docDuration + appliedCoverPreRoll,
1103
+ appliedCoverPreRoll,
1104
+ ffmpegPath
1105
+ };
1034
1106
  }
1035
- }
1036
- return {
1037
- frames: capturedFrames.release(),
1038
- totalDuration: docDuration + appliedCoverPreRoll,
1039
- appliedCoverPreRoll,
1040
- ffmpegPath
1041
- };
1107
+ );
1042
1108
  } catch (err) {
1043
1109
  capturedFrames.clear();
1044
1110
  signal?.throwIfAborted();
1045
1111
  throw err;
1046
- } finally {
1047
- signal?.removeEventListener("abort", handleAbort);
1048
- await renderAPI?.dispose().catch(() => void 0);
1049
- await browser.close().catch(() => void 0);
1050
1112
  }
1051
1113
  }
1114
+ async function renderDocToDashboardPng(doc, container, options = {}) {
1115
+ const {
1116
+ signal,
1117
+ outputPath,
1118
+ resolution,
1119
+ width,
1120
+ height,
1121
+ layout,
1122
+ title,
1123
+ style,
1124
+ documentTitle,
1125
+ onProgress
1126
+ } = options;
1127
+ signal?.throwIfAborted();
1128
+ const dimensions = resolveDashboardDimensions({ resolution, width, height });
1129
+ const bytes = await withRenderPage(
1130
+ doc,
1131
+ container,
1132
+ {
1133
+ signal,
1134
+ width: dimensions.width,
1135
+ height: dimensions.height,
1136
+ includeAudio: false,
1137
+ animationsEnabled: false,
1138
+ displayMode: "dashboard",
1139
+ dashboard: { layout, title, style, documentTitle },
1140
+ onProgress
1141
+ },
1142
+ async ({ page, renderAPI }) => {
1143
+ onProgress?.("rendering dashboard", 50);
1144
+ signal?.throwIfAborted();
1145
+ await renderAPI.evaluate((api) => api.seekTo(0));
1146
+ signal?.throwIfAborted();
1147
+ await page.waitForTimeout(100);
1148
+ signal?.throwIfAborted();
1149
+ onProgress?.("capturing image", 85);
1150
+ return await page.screenshot({ type: "png" });
1151
+ }
1152
+ );
1153
+ let writtenPath;
1154
+ if (outputPath) {
1155
+ const absolute = resolvePath(outputPath);
1156
+ await mkdir(dirname2(absolute), { recursive: true });
1157
+ await writeFile(absolute, bytes);
1158
+ writtenPath = absolute;
1159
+ }
1160
+ onProgress?.("done", 100);
1161
+ return {
1162
+ bytes,
1163
+ width: dimensions.width,
1164
+ height: dimensions.height,
1165
+ ...writtenPath ? { outputPath: writtenPath } : {}
1166
+ };
1167
+ }
1052
1168
  async function renderDocToMp4(doc, container, options) {
1053
1169
  options.signal?.throwIfAborted();
1054
1170
  const fps = options.fps ?? 30;
@@ -1231,6 +1347,8 @@ async function extractThumbnails(options) {
1231
1347
  export {
1232
1348
  CapturedFrameBudgetError,
1233
1349
  ConversionError,
1350
+ DASHBOARD_RESOLUTIONS,
1351
+ DEFAULT_DASHBOARD_RESOLUTION,
1234
1352
  GIF_EXPORT_DEFAULTS,
1235
1353
  MAX_CAPTURED_FRAME_BYTES,
1236
1354
  MemoryContentContainer2 as MemoryContentContainer,
@@ -1243,6 +1361,9 @@ export {
1243
1361
  framesToMp4NativeBytes,
1244
1362
  prepareConversion,
1245
1363
  readInput,
1364
+ renderDocToDashboardPng,
1246
1365
  renderDocToGif,
1247
- renderDocToMp4
1366
+ renderDocToMp4,
1367
+ resolveDashboardDimensions2 as resolveDashboardDimensions,
1368
+ validateDashboardImageDimensions
1248
1369
  };