@bendyline/squisq-cli 2.8.8 → 2.8.10

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
@@ -89,12 +89,18 @@ squisq video doc.json -o out.mp4
89
89
  | `--width` / `--height` | Dimension overrides in pixels — **must be even** | MP4 1080p; GIF 960×540 |
90
90
  | `--overwrite` | Replace an existing output file (otherwise refuse and exit non-zero) | off |
91
91
  | `--no-auto-templates` | Disable content-aware template auto-picking for unannotated headings | (auto on) |
92
+ | `--frame-transport` | MP4 frame delivery: `pipe` (stream stills to ffmpeg) or `memory` | `pipe` |
93
+ | `--capture-format` | MP4 still captured per frame: `png` or `jpeg` (pipe only) | high `png`, else `jpeg` |
94
+ | `--frames-dir` | MP4 only: spool every captured still here and keep it after the run | none |
95
+ | `--resume` | MP4 only: reuse stills an identical render spooled in `--frames-dir` | off |
92
96
 
93
97
  Notes:
94
98
 
95
99
  - **Dimensions must be even.** `--width 851` is rejected immediately — before the document is read or a browser launches — with `Video width must be an even number of pixels (got 851) … Use 850 or 852.` Odd values are rejected rather than rounded, so a render never silently ships at a size you did not ask for. The rule is the same for MP4 and GIF, and matches browser export.
96
100
  - **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
101
  - 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.
102
+ - **MP4 frames stream to ffmpeg as they are captured.** Memory stays flat for a story of any length; `--frame-transport memory` restores the old retain-then-encode path, which is bounded to a few seconds of 1080p photo slides. `--frames-dir` keeps every still (plus a `manifest.json` naming the doc, size, rate, captions and animation settings), and `--resume` skips re-capturing stills that an identical render already spooled there.
103
+ - **Portrait and custom sizes are composed natively.** The render page pins the player's viewport to the export size; a player bundle that ignores it fails the run before capture instead of producing a letterboxed landscape layout.
98
104
 
99
105
  ### `squisq image <input> [output]`
100
106
 
@@ -230,8 +236,13 @@ const result = await renderDocToMp4(doc, container, {
230
236
  captionStyle: 'social', // 'off' | 'standard' | 'social' (default 'off')
231
237
  animationsEnabled: false, // static slide changes; media/timing remain active
232
238
  coverPreRoll: 2, // seconds of cover-slide pre-roll (default 0)
239
+ frameTransport: 'pipe', // 'pipe' (default; streams stills to ffmpeg) | 'memory'
240
+ captureFormat: 'png', // 'png' | 'jpeg' (default: png for high quality, jpeg otherwise)
241
+ framesDir: './frames', // optional: keep every still + manifest; with resume: true, reuse them
242
+ onFrame: ({ index, totalFrames, captureMs, reused }) => {}, // per-frame throughput hook
233
243
  onProgress: (phase, pct) => console.log(`${phase}: ${pct}%`),
234
244
  });
245
+ // result: { duration, frameCount, outputPath, reusedFrameCount?, framesDir? }
235
246
 
236
247
  console.log(`Rendered ${result.frameCount} frames (${result.duration}s) → ${result.outputPath}`);
237
248
  ```
@@ -9,6 +9,7 @@ import {
9
9
  convert,
10
10
  createCliRegistry,
11
11
  extractThumbnails,
12
+ ffmpegPipeArgs,
12
13
  prepareConversion,
13
14
  readInput,
14
15
  renderDocCoverToPng,
@@ -17,14 +18,14 @@ import {
17
18
  renderDocToMp4,
18
19
  resolveDashboardDimensions,
19
20
  validateDashboardImageDimensions
20
- } from "./chunk-XD2ICI5G.js";
21
+ } from "./chunk-4FJZYJC3.js";
21
22
  import {
22
23
  GIF_EXPORT_DEFAULTS,
23
24
  framesToGifNative,
24
25
  framesToGifNativeBytes,
25
26
  framesToMp4Native,
26
27
  framesToMp4NativeBytes
27
- } from "./chunk-GGVASRPG.js";
28
+ } from "./chunk-Y5XKUWJ5.js";
28
29
  export {
29
30
  CapturedFrameBudgetError,
30
31
  ConversionError,
@@ -36,6 +37,7 @@ export {
36
37
  convert,
37
38
  createCliRegistry,
38
39
  extractThumbnails,
40
+ ffmpegPipeArgs,
39
41
  framesToGifNative,
40
42
  framesToGifNativeBytes,
41
43
  framesToMp4Native,
package/dist/api.d.ts CHANGED
@@ -8,6 +8,66 @@ import { FormatRegistry, FormatId, ConvertOptions, ConvertSource, ConversionResu
8
8
  export { ConversionError, ConversionErrorCode, ConversionErrorOptions, ConversionResult, ConvertOptions, ConvertSource, FormatDefinition, FormatId, FormatRegistry, NormalizedInput, PreparedConversion, PreparedExportOptions } from '@bendyline/squisq-formats';
9
9
  import { MarkdownDocument } from '@bendyline/squisq/markdown';
10
10
 
11
+ /**
12
+ * Frame sinks for offline MP4 rendering.
13
+ *
14
+ * The capture loop produces one encoded still (PNG or JPEG) per output frame
15
+ * and hands it to a sink. Two kinds exist:
16
+ *
17
+ * - {@link createFfmpegPipeSink} streams stills into one long-running ffmpeg
18
+ * process over stdin (`image2pipe`) so x264 encodes as frames arrive and
19
+ * memory stays flat however long the document is. This replaced the
20
+ * retain-every-PNG-then-encode design, whose 256 MB budget held only a few
21
+ * seconds of 1080p photo slides.
22
+ * - {@link openDirectoryFrameStore} spools stills to a directory next to a
23
+ * manifest, so a render can be inspected frame by frame and resumed after
24
+ * an interruption without re-capturing frames already on disk.
25
+ *
26
+ * Related: api.ts (`renderDocToMp4`), runFfmpeg.ts (stderr digest),
27
+ * @bendyline/squisq-video ffmpegArgs.ts (quality and mux flags).
28
+ *
29
+ * Gotchas: the pipe sink honours stdin backpressure, so a slow encoder slows
30
+ * capture rather than buffering frames. A wall-clock timeout would fire on
31
+ * every long render, so the sink watches for silence instead: a frame write
32
+ * or an ffmpeg stderr line counts as activity.
33
+ */
34
+
35
+ /** Still-image format captured from the browser for each frame. */
36
+ type CaptureFormat = 'png' | 'jpeg';
37
+ /** Everything the pipe encoder needs; mirrors the memory path's ffmpeg flags. */
38
+ interface FfmpegPipeSinkOptions {
39
+ ffmpegPath: string;
40
+ outputPath: string;
41
+ fps: number;
42
+ width: number;
43
+ height: number;
44
+ quality: VideoQuality;
45
+ captureFormat: CaptureFormat;
46
+ /** Pre-mixed audio file to mux, or null for a silent video. */
47
+ audioPath: string | null;
48
+ signal?: AbortSignal;
49
+ /** Fail when neither a frame nor encoder output arrives for this long (default 120 s). */
50
+ idleTimeoutMs?: number;
51
+ }
52
+ /** ffmpeg arguments for the streaming (`image2pipe`) H.264 encode. Pure; unit-tested. */
53
+ declare function ffmpegPipeArgs(options: Omit<FfmpegPipeSinkOptions, 'ffmpegPath' | 'signal' | 'idleTimeoutMs'>): string[];
54
+ /**
55
+ * Identity of a spooled render. A spool is reusable only when every value
56
+ * matches: a different doc, size, rate, caption or animation setting changes
57
+ * the pixels, and a different capture format changes the bytes on disk.
58
+ */
59
+ interface FrameManifest {
60
+ generatedBy: 'squisq-cli';
61
+ docHash: string;
62
+ fps: number;
63
+ width: number;
64
+ height: number;
65
+ captionStyle: string | null;
66
+ animationsEnabled: boolean;
67
+ coverPreRoll: number;
68
+ captureFormat: CaptureFormat;
69
+ }
70
+
11
71
  /**
12
72
  * CLI format registry.
13
73
  *
@@ -286,6 +346,36 @@ interface RenderDocToMp4Options {
286
346
  captionStyle?: 'off' | 'standard' | 'social';
287
347
  /** Render layer animations and block transitions (default: true). */
288
348
  animationsEnabled?: boolean;
349
+ /**
350
+ * How captured stills reach the encoder (default: 'pipe').
351
+ *
352
+ * - 'pipe' streams each still into one long-running ffmpeg process as it is
353
+ * captured, so memory stays flat for documents of any length.
354
+ * - 'memory' retains every still until capture completes, then encodes.
355
+ * Bounded by {@link MAX_CAPTURED_FRAME_BYTES} (a few seconds of 1080p
356
+ * photo slides); kept for callers that depend on the old semantics.
357
+ */
358
+ frameTransport?: 'pipe' | 'memory';
359
+ /**
360
+ * Still-image format captured from the browser per frame (default: 'png'
361
+ * for high quality, otherwise 'jpeg'). Chromium encodes a 1080p PNG in
362
+ * hundreds of milliseconds but a JPEG in tens, and JPEG stills are
363
+ * converted to limited-range yuv420p and indistinguishable after H.264 at
364
+ * draft/normal CRF; PNG keeps the capture lossless for the high preset.
365
+ * Pipe transport only.
366
+ */
367
+ captureFormat?: CaptureFormat;
368
+ /**
369
+ * Spool every captured still into this directory next to a manifest, and
370
+ * keep them after the render for inspection. With `resume`, a spool whose
371
+ * manifest matches this render supplies its frames instead of re-capturing
372
+ * them. Pipe transport only.
373
+ */
374
+ framesDir?: string;
375
+ /** Reuse frames already present in `framesDir` from an identical render. */
376
+ resume?: boolean;
377
+ /** Per-frame callback for hosts that measure throughput or tee frames. Pipe transport only. */
378
+ onFrame?: (frame: RenderedFrameInfo) => void;
289
379
  /**
290
380
  * Seconds of cover-slide pre-roll before the story starts (default: 0).
291
381
  *
@@ -330,6 +420,20 @@ interface RenderDocToGifOptions {
330
420
  /** Progress callback. */
331
421
  onProgress?: (phase: string, percent: number) => void;
332
422
  }
423
+ /** One output frame as it passes through the render loop. */
424
+ interface RenderedFrameInfo {
425
+ /** Zero-based index in the output sequence; cover pre-roll frames come first. */
426
+ index: number;
427
+ /** Timeline second rendered (0 during the cover pre-roll). */
428
+ time: number;
429
+ isCover: boolean;
430
+ /** Frames the render emits in total, pre-roll included. */
431
+ totalFrames: number;
432
+ /** True when the still came from a resumed spool instead of a capture. */
433
+ reused: boolean;
434
+ /** Milliseconds spent seeking and capturing (0 when reused). */
435
+ captureMs: number;
436
+ }
333
437
  /** Result returned by renderDocToMp4. */
334
438
  interface RenderDocToMp4Result {
335
439
  /** Duration of the rendered video in seconds (including pre-roll). */
@@ -338,6 +442,10 @@ interface RenderDocToMp4Result {
338
442
  frameCount: number;
339
443
  /** Output file path. */
340
444
  outputPath: string;
445
+ /** Frames supplied by a resumed spool instead of a capture (pipe transport). */
446
+ reusedFrameCount?: number;
447
+ /** Spool directory holding every captured still, when one was requested. */
448
+ framesDir?: string;
341
449
  }
342
450
  /** Result returned by renderDocToGif. */
343
451
  interface RenderDocToGifResult extends RenderDocToMp4Result {
@@ -442,4 +550,4 @@ interface ExtractThumbnailsOptions {
442
550
  */
443
551
  declare function extractThumbnails(options: ExtractThumbnailsOptions): Promise<void>;
444
552
 
445
- 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 RenderCoverPngOptions, type RenderCoverPngResult, type RenderDashboardPngOptions, type RenderDashboardPngResult, type RenderDocToGifOptions, type RenderDocToGifResult, type RenderDocToMp4Options, type RenderDocToMp4Result, type ThumbnailSpec, convert, createCliRegistry, extractThumbnails, framesToGifNative, framesToGifNativeBytes, framesToMp4Native, framesToMp4NativeBytes, prepareConversion, readInput, renderDocCoverToPng, renderDocToDashboardPng, renderDocToGif, renderDocToMp4 };
553
+ export { type CaptureFormat, CapturedFrameBudgetError, type CliConvertOptions, type ExtractThumbnailsOptions, type FrameManifest, GIF_EXPORT_DEFAULTS, type GifExportOptions, type GifFormatOptions, MAX_CAPTURED_FRAME_BYTES, type Mp4FormatOptions, type NativeVideoExportOptions, type PngFormatOptions, type ReadInputResult, type RenderCoverPngOptions, type RenderCoverPngResult, type RenderDashboardPngOptions, type RenderDashboardPngResult, type RenderDocToGifOptions, type RenderDocToGifResult, type RenderDocToMp4Options, type RenderDocToMp4Result, type RenderedFrameInfo, type ThumbnailSpec, convert, createCliRegistry, extractThumbnails, ffmpegPipeArgs, framesToGifNative, framesToGifNativeBytes, framesToMp4Native, framesToMp4NativeBytes, prepareConversion, readInput, renderDocCoverToPng, renderDocToDashboardPng, renderDocToGif, renderDocToMp4 };