@bendyline/squisq-cli 2.8.9 → 2.8.11

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
@@ -84,17 +84,24 @@ squisq video doc.json -o out.mp4
84
84
  | `--dither` | GIF dithering: `bayer`, `sierra2_4a`, or `none` | `sierra2_4a` |
85
85
  | `--bayer-scale` | Ordered Bayer strength, 0–5 | 3 |
86
86
  | `-t, --theme` | Squisq theme id to apply | none |
87
+ | `--motion` | Motion profile: `calm`, `documentary` or `vibrant` | doc's, then theme's |
87
88
  | `--transform` | Transform style to apply before rendering | none |
88
89
  | `--cover-preroll` | Seconds of cover-slide pre-roll before the story starts | 2 |
89
90
  | `--width` / `--height` | Dimension overrides in pixels — **must be even** | MP4 1080p; GIF 960×540 |
90
91
  | `--overwrite` | Replace an existing output file (otherwise refuse and exit non-zero) | off |
91
92
  | `--no-auto-templates` | Disable content-aware template auto-picking for unannotated headings | (auto on) |
93
+ | `--frame-transport` | MP4 frame delivery: `pipe` (stream stills to ffmpeg) or `memory` | `pipe` |
94
+ | `--capture-format` | MP4 still captured per frame: `png` or `jpeg` (pipe only) | high `png`, else `jpeg` |
95
+ | `--frames-dir` | MP4 only: spool every captured still here and keep it after the run | none |
96
+ | `--resume` | MP4 only: reuse stills an identical render spooled in `--frames-dir` | off |
92
97
 
93
98
  Notes:
94
99
 
95
100
  - **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
101
  - **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
102
  - 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.
103
+ - **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.
104
+ - **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
105
 
99
106
  ### `squisq image <input> [output]`
100
107
 
@@ -230,8 +237,13 @@ const result = await renderDocToMp4(doc, container, {
230
237
  captionStyle: 'social', // 'off' | 'standard' | 'social' (default 'off')
231
238
  animationsEnabled: false, // static slide changes; media/timing remain active
232
239
  coverPreRoll: 2, // seconds of cover-slide pre-roll (default 0)
240
+ frameTransport: 'pipe', // 'pipe' (default; streams stills to ffmpeg) | 'memory'
241
+ captureFormat: 'png', // 'png' | 'jpeg' (default: png for high quality, jpeg otherwise)
242
+ framesDir: './frames', // optional: keep every still + manifest; with resume: true, reuse them
243
+ onFrame: ({ index, totalFrames, captureMs, reused }) => {}, // per-frame throughput hook
233
244
  onProgress: (phase, pct) => console.log(`${phase}: ${pct}%`),
234
245
  });
246
+ // result: { duration, frameCount, outputPath, reusedFrameCount?, framesDir? }
235
247
 
236
248
  console.log(`Rendered ${result.frameCount} frames (${result.duration}s) → ${result.outputPath}`);
237
249
  ```
@@ -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-6MZC4YQV.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
@@ -1,4 +1,4 @@
1
- import { Doc } from '@bendyline/squisq/schemas';
1
+ import { Doc, MotionSpec } from '@bendyline/squisq/schemas';
2
2
  import { DashboardStyleId } from '@bendyline/squisq/doc';
3
3
  import { ContentContainer } from '@bendyline/squisq/storage';
4
4
  export { MemoryContentContainer } from '@bendyline/squisq/storage';
@@ -8,6 +8,68 @@ 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
+ /** JSON of the motion spec the frames were captured with (`null` = doc/theme default). */
67
+ motion?: string | null;
68
+ animationsEnabled: boolean;
69
+ coverPreRoll: number;
70
+ captureFormat: CaptureFormat;
71
+ }
72
+
11
73
  /**
12
74
  * CLI format registry.
13
75
  *
@@ -284,8 +346,44 @@ interface RenderDocToMp4Options {
284
346
  height?: number;
285
347
  /** Caption mode to bake into the video (default: off). */
286
348
  captionStyle?: 'off' | 'standard' | 'social';
349
+ /**
350
+ * Motion profile override: `calm` (pre-profile output), `documentary` or
351
+ * `vibrant`, or a spec with overrides. Omitted → the doc's `motion` /
352
+ * frontmatter `squisq-motion`, then the theme's `renderStyle.motionProfile`.
353
+ */
354
+ motion?: MotionSpec | null;
287
355
  /** Render layer animations and block transitions (default: true). */
288
356
  animationsEnabled?: boolean;
357
+ /**
358
+ * How captured stills reach the encoder (default: 'pipe').
359
+ *
360
+ * - 'pipe' streams each still into one long-running ffmpeg process as it is
361
+ * captured, so memory stays flat for documents of any length.
362
+ * - 'memory' retains every still until capture completes, then encodes.
363
+ * Bounded by {@link MAX_CAPTURED_FRAME_BYTES} (a few seconds of 1080p
364
+ * photo slides); kept for callers that depend on the old semantics.
365
+ */
366
+ frameTransport?: 'pipe' | 'memory';
367
+ /**
368
+ * Still-image format captured from the browser per frame (default: 'png'
369
+ * for high quality, otherwise 'jpeg'). Chromium encodes a 1080p PNG in
370
+ * hundreds of milliseconds but a JPEG in tens, and JPEG stills are
371
+ * converted to limited-range yuv420p and indistinguishable after H.264 at
372
+ * draft/normal CRF; PNG keeps the capture lossless for the high preset.
373
+ * Pipe transport only.
374
+ */
375
+ captureFormat?: CaptureFormat;
376
+ /**
377
+ * Spool every captured still into this directory next to a manifest, and
378
+ * keep them after the render for inspection. With `resume`, a spool whose
379
+ * manifest matches this render supplies its frames instead of re-capturing
380
+ * them. Pipe transport only.
381
+ */
382
+ framesDir?: string;
383
+ /** Reuse frames already present in `framesDir` from an identical render. */
384
+ resume?: boolean;
385
+ /** Per-frame callback for hosts that measure throughput or tee frames. Pipe transport only. */
386
+ onFrame?: (frame: RenderedFrameInfo) => void;
289
387
  /**
290
388
  * Seconds of cover-slide pre-roll before the story starts (default: 0).
291
389
  *
@@ -315,6 +413,8 @@ interface RenderDocToGifOptions {
315
413
  height?: number;
316
414
  /** Caption mode to bake into the GIF (default: standard). */
317
415
  captionStyle?: 'off' | 'standard' | 'social';
416
+ /** Motion profile override (see {@link RenderDocToMp4Options.motion}). */
417
+ motion?: MotionSpec | null;
318
418
  /** Seconds of cover-slide pre-roll (default: 0). */
319
419
  coverPreRoll?: number;
320
420
  /** Render layer animations and block transitions (default: false). */
@@ -330,6 +430,20 @@ interface RenderDocToGifOptions {
330
430
  /** Progress callback. */
331
431
  onProgress?: (phase: string, percent: number) => void;
332
432
  }
433
+ /** One output frame as it passes through the render loop. */
434
+ interface RenderedFrameInfo {
435
+ /** Zero-based index in the output sequence; cover pre-roll frames come first. */
436
+ index: number;
437
+ /** Timeline second rendered (0 during the cover pre-roll). */
438
+ time: number;
439
+ isCover: boolean;
440
+ /** Frames the render emits in total, pre-roll included. */
441
+ totalFrames: number;
442
+ /** True when the still came from a resumed spool instead of a capture. */
443
+ reused: boolean;
444
+ /** Milliseconds spent seeking and capturing (0 when reused). */
445
+ captureMs: number;
446
+ }
333
447
  /** Result returned by renderDocToMp4. */
334
448
  interface RenderDocToMp4Result {
335
449
  /** Duration of the rendered video in seconds (including pre-roll). */
@@ -338,6 +452,10 @@ interface RenderDocToMp4Result {
338
452
  frameCount: number;
339
453
  /** Output file path. */
340
454
  outputPath: string;
455
+ /** Frames supplied by a resumed spool instead of a capture (pipe transport). */
456
+ reusedFrameCount?: number;
457
+ /** Spool directory holding every captured still, when one was requested. */
458
+ framesDir?: string;
341
459
  }
342
460
  /** Result returned by renderDocToGif. */
343
461
  interface RenderDocToGifResult extends RenderDocToMp4Result {
@@ -442,4 +560,4 @@ interface ExtractThumbnailsOptions {
442
560
  */
443
561
  declare function extractThumbnails(options: ExtractThumbnailsOptions): Promise<void>;
444
562
 
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 };
563
+ 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 };