@bendyline/squisq-video 1.2.1 → 2.0.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
@@ -1,6 +1,6 @@
1
1
  # @bendyline/squisq-video
2
2
 
3
- Headless video rendering foundation for Squisq documents. Generates self-contained render HTML for frame capture and encodes frames to MP4 via ffmpeg.wasm. Browser-pure works in Node.js and the browser with no native dependencies.
3
+ Video and animated-GIF rendering foundation for Squisq documents. Its pure timeline, quality, palette, and render-HTML helpers work in Node.js and browsers; `framesToMp4Wasm` encodes frames in browser runtimes without native dependencies.
4
4
 
5
5
  Part of the [Squisq](https://github.com/bendyline/squisq) monorepo.
6
6
 
@@ -15,15 +15,18 @@ npm install @bendyline/squisq-video
15
15
 
16
16
  ## What's Inside
17
17
 
18
- | Export | Description |
19
- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
20
- | `generateRenderHtml(doc, options)` | Self-contained HTML page with embedded media for headless frame capture |
21
- | `framesToMp4Wasm(frames, audio, options)` | Encode PNG frames (+ optional audio) to MP4 via ffmpeg.wasm |
22
- | `computeAudioTimeline(doc, coverPreRoll?)` | Flatten a doc's narration + timed media into absolute-timed audio clips (browser export and CLI mix share this) |
23
- | `bitrateForQuality(quality, width, height)` | Target H.264 bitrate (`w*h*bitsPerPixel`) — the single source of truth for WebCodecs bitrate |
24
- | `QUALITY_PRESETS`, `ORIENTATION_DIMENSIONS`, `resolveDimensions` | Quality/dimension presets and helpers |
25
- | `VideoExportOptions`, `VideoQuality`, `VideoOrientation`, `QualityPreset`, `EncoderResult`, `AudioTimelineClip` | Shared types |
26
- | `fetchFile` | Re-export of `@ffmpeg/util`'s `fetchFile` for preparing audio bytes |
18
+ | Export | Description |
19
+ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
20
+ | `generateRenderHtml(doc, options)` | Self-contained HTML page with embedded media for headless frame capture |
21
+ | `framesToMp4Wasm(frames, audio, options)` | Encode PNG frames (+ optional audio) to MP4 via ffmpeg.wasm |
22
+ | `computeAudioTimeline(doc, coverPreRoll?)` | Flatten a doc's narration + timed media into absolute-timed audio clips (browser export and CLI mix share this) |
23
+ | `bitrateForQuality(quality, width, height)` | Target H.264 bitrate (`w*h*bitsPerPixel`) — the single source of truth for WebCodecs bitrate |
24
+ | `ffmpegVideoQualityArgs`, `audioBitrateArg`, `ffmpegAudioMuxArgs` | Shared FFmpeg flags; audio muxing pads short narration so it cannot truncate the video |
25
+ | `ffmpegGifFilterGraph`, `ffmpegGifOutputArgs` | Shared global-palette GIF filters and muxer flags used by native and browser exporters |
26
+ | `QUALITY_PRESETS`, `ORIENTATION_DIMENSIONS`, `resolveDimensions` | Quality/dimension presets and helpers |
27
+ | `validateVideoExportOptions(options)` | Fail-fast runtime validation for FPS, dimensions, quality, and orientation |
28
+ | `VideoExportOptions`, `VideoQuality`, `VideoOrientation`, `GifDither`, `QualityPreset`, `EncoderResult` | Shared video and GIF types |
29
+ | `fetchFile` | Re-export of `@ffmpeg/util`'s `fetchFile` for preparing audio bytes |
27
30
 
28
31
  This package is the shared foundation under [@bendyline/squisq-video-react](https://www.npmjs.com/package/@bendyline/squisq-video-react) (in-browser export UI) and [@bendyline/squisq-cli](https://www.npmjs.com/package/@bendyline/squisq-cli) (`squisq video`, which pairs the render HTML with Playwright capture and native ffmpeg encoding).
29
32
 
@@ -31,7 +34,10 @@ This package is the shared foundation under [@bendyline/squisq-video-react](http
31
34
 
32
35
  ### Generate Render HTML
33
36
 
34
- Create a self-contained HTML page that mounts the standalone Squisq player in render mode, with all images and audio embedded as base64 data URIs. The page exposes `window.seekTo(time)` and `window.getDuration()` so a headless browser (Playwright, Puppeteer) can step through frames and screenshot each one:
37
+ Create a self-contained HTML page that mounts the standalone Squisq player in render mode, with all images and audio embedded as base64 data URIs. A headless browser (Playwright, Puppeteer) obtains the player-specific handle with `SquisqPlayer.getHandle(root)` and uses its render API to step through frames and screenshot each one:
38
+
39
+ Render methods are deliberately instance-scoped; generated pages do not expose
40
+ legacy top-level `window.seekTo` or `window.getDuration` functions.
35
41
 
36
42
  ```ts
37
43
  import { generateRenderHtml } from '@bendyline/squisq-video';
@@ -60,13 +66,18 @@ const { data, duration } = await framesToMp4Wasm(
60
66
  quality: 'normal', // 'draft' | 'normal' | 'high' (default 'normal')
61
67
  orientation: 'landscape', // 'landscape' | 'portrait' (default 'landscape')
62
68
  // width / height override the orientation defaults
69
+ // Optional for offline/CSP-controlled hosting:
70
+ ffmpegWasm: {
71
+ coreURL: '/vendor/ffmpeg-core.js',
72
+ wasmURL: '/vendor/ffmpeg-core.wasm',
73
+ },
63
74
  onProgress: (percent, phase) => console.log(`${phase}: ${percent}%`),
64
75
  },
65
76
  );
66
77
  // data: Uint8Array of MP4 bytes; duration: seconds (frames.length / fps)
67
78
  ```
68
79
 
69
- Encoding is H.264 (`libx264`, `yuv420p`) with optional AAC audio; frames are scaled/padded to the target dimensions preserving aspect ratio. ffmpeg.wasm needs `SharedArrayBuffer` in browsers that means COOP/COEP headers; Node 18+ works out of the box.
80
+ Encoding is H.264 (`libx264`, `yuv420p`) with optional AAC audio; frames are scaled/padded to the target dimensions preserving aspect ratio. ffmpeg.wasm needs `SharedArrayBuffer`, which normally means serving COOP/COEP headers. `framesToMp4Wasm` is browser-only; Node callers can use `framesToMp4Native` or `framesToMp4NativeBytes` from `@bendyline/squisq-cli/api`.
70
81
 
71
82
  ### Schedule a Doc's Audio
72
83
 
package/dist/index.d.ts CHANGED
@@ -18,6 +18,13 @@ export { fetchFile } from '@ffmpeg/util';
18
18
  type VideoQuality = 'draft' | 'normal' | 'high';
19
19
  /** Viewport orientation for video output. */
20
20
  type VideoOrientation = 'landscape' | 'portrait';
21
+ /** Optional URLs for self-hosting or pinning ffmpeg.wasm runtime assets. */
22
+ interface FfmpegWasmLoadConfig {
23
+ coreURL?: string;
24
+ wasmURL?: string;
25
+ workerURL?: string;
26
+ classWorkerURL?: string;
27
+ }
21
28
  /** Encoding preset parameters mapped from VideoQuality. */
22
29
  interface QualityPreset {
23
30
  /** FFmpeg -preset value (ultrafast, medium, slow) */
@@ -64,6 +71,8 @@ interface VideoExportOptions {
64
71
  quality?: VideoQuality;
65
72
  /** Viewport orientation (default: 'landscape') */
66
73
  orientation?: VideoOrientation;
74
+ /** Optional self-hosted ffmpeg.wasm assets for offline/CSP-controlled use. */
75
+ ffmpegWasm?: FfmpegWasmLoadConfig;
67
76
  /**
68
77
  * Progress callback. Called during encoding with completion percentage and phase description.
69
78
  * @param percent - 0-100 completion percentage
@@ -78,6 +87,8 @@ interface EncoderResult {
78
87
  /** Video duration in seconds */
79
88
  duration: number;
80
89
  }
90
+ /** Fail fast with actionable errors before launching an encoder or browser. */
91
+ declare function validateVideoExportOptions(options: VideoExportOptions): void;
81
92
  /**
82
93
  * Resolve dimensions from options, applying orientation defaults.
83
94
  */
@@ -135,9 +146,9 @@ declare function computeAudioTimeline(doc: Doc, coverPreRoll?: number): AudioTim
135
146
  * Generates a self-contained HTML document that loads the SquisqPlayer standalone
136
147
  * bundle in renderMode, embedding all images and audio as base64 data URIs.
137
148
  *
138
- * The generated page exposes `window.seekTo(time)`, `window.getDuration()`, etc.
139
- * via the SquisqRenderAPI, enabling Playwright (or any headless browser) to step
140
- * through frames and capture screenshots.
149
+ * The generated page mounts one standalone player whose instance handle is
150
+ * available through `SquisqPlayer.getHandle(root)`. Headless callers use that
151
+ * handle's render API to step through frames and capture screenshots.
141
152
  *
142
153
  * Browser-pure: uses only btoa() and Uint8Array — no Node.js APIs.
143
154
  */
@@ -161,12 +172,17 @@ interface RenderHtmlOptions {
161
172
  height?: number;
162
173
  /** Caption style for the rendered video. Omit for no captions. */
163
174
  captionStyle?: 'standard' | 'social';
175
+ /**
176
+ * Whether Squisq layer animations and block transitions are rendered.
177
+ * Defaults to true. Embedded/timed media and document timing are unaffected.
178
+ */
179
+ animationsEnabled?: boolean;
164
180
  }
165
181
  /**
166
182
  * Generate a self-contained HTML document for headless video frame capture.
167
183
  *
168
- * The page mounts the SquisqPlayer in renderMode, which exposes the
169
- * SquisqRenderAPI on `window` (seekTo, getDuration, getCaptions, etc.).
184
+ * The page mounts the SquisqPlayer in renderMode. Call
185
+ * `SquisqPlayer.getHandle(root)` to obtain this instance's render API.
170
186
  *
171
187
  * @param doc - The Doc to render
172
188
  * @param options - Render HTML options including player script and media
@@ -186,6 +202,24 @@ declare function generateRenderHtml(doc: Doc, options: RenderHtmlOptions): strin
186
202
  * exercise directly).
187
203
  */
188
204
 
205
+ /** Dithering algorithms intentionally exposed by Squisq's GIF encoder. */
206
+ type GifDither = 'bayer' | 'sierra2_4a' | 'none';
207
+ /** Options for the shared palette-based animated GIF filter graph. */
208
+ interface GifFilterOptions {
209
+ width: number;
210
+ height: number;
211
+ /** Palette size, from 2 through GIF's maximum of 256. */
212
+ maxColors?: number;
213
+ /** Palette dithering algorithm (default: `sierra2_4a`). */
214
+ dither?: GifDither;
215
+ /** Ordered Bayer strength, used only when `dither` is `bayer` (0-5). */
216
+ bayerScale?: number;
217
+ }
218
+ /** Options for GIF muxing in addition to the palette filter graph. */
219
+ interface GifOutputOptions extends GifFilterOptions {
220
+ /** Number of repeats; 0 loops forever and -1 disables looping. */
221
+ loop?: number;
222
+ }
189
223
  /**
190
224
  * H.264 speed/quality flags (`-preset`, `-crf`) for a quality level.
191
225
  * @example ffmpegVideoQualityArgs('high') // ['-preset', 'slow', '-crf', '18']
@@ -196,13 +230,31 @@ declare function ffmpegVideoQualityArgs(quality: VideoQuality): string[];
196
230
  * @example audioBitrateArg('high') // '192k'
197
231
  */
198
232
  declare function audioBitrateArg(quality: VideoQuality): string;
233
+ /**
234
+ * AAC muxing flags that preserve the complete video timeline.
235
+ *
236
+ * `-shortest` by itself truncates video whenever narration ends early. Padding
237
+ * the audio stream first makes the video stream the shortest input instead, so
238
+ * audio longer than the video is trimmed while shorter audio becomes silence.
239
+ */
240
+ declare function ffmpegAudioMuxArgs(bitrate: string | number): string[];
241
+ /**
242
+ * Build a high-quality, compression-friendly GIF palette filter graph.
243
+ *
244
+ * A single palette is derived from the parts of the frame that change, while
245
+ * `diff_mode=rectangle` keeps error diffusion inside the changed rectangle so
246
+ * static slide backgrounds remain byte-stable and compress efficiently.
247
+ */
248
+ declare function ffmpegGifFilterGraph(options: GifFilterOptions): string;
249
+ /** Build the output-side FFmpeg arguments for an animated GIF. */
250
+ declare function ffmpegGifOutputArgs(options: GifOutputOptions): string[];
199
251
 
200
252
  /**
201
253
  * WASM Video Encoder
202
254
  *
203
255
  * Encodes PNG frame screenshots into an MP4 video using ffmpeg.wasm.
204
- * Browser-pure — no Node.js APIs. Works in any environment with SharedArrayBuffer
205
- * support (browsers with COOP/COEP headers, or Node 18+).
256
+ * Browser-pure — no Node.js APIs. The encoder requires a browser runtime with
257
+ * SharedArrayBuffer support (normally supplied via COOP/COEP headers).
206
258
  *
207
259
  * Uses @ffmpeg/ffmpeg for H.264 encoding and optional AAC audio muxing.
208
260
  */
@@ -217,4 +269,4 @@ declare function audioBitrateArg(quality: VideoQuality): string;
217
269
  */
218
270
  declare function framesToMp4Wasm(frames: Uint8Array[], audio: Uint8Array | null, options?: VideoExportOptions): Promise<EncoderResult>;
219
271
 
220
- export { type AudioTimelineClip, type EncoderResult, ORIENTATION_DIMENSIONS, QUALITY_PRESETS, type QualityPreset, type RenderHtmlOptions, type VideoExportOptions, type VideoOrientation, type VideoQuality, audioBitrateArg, bitrateForQuality, computeAudioTimeline, ffmpegVideoQualityArgs, framesToMp4Wasm, generateRenderHtml, resolveDimensions };
272
+ export { type AudioTimelineClip, type EncoderResult, type FfmpegWasmLoadConfig, type GifDither, type GifFilterOptions, type GifOutputOptions, ORIENTATION_DIMENSIONS, QUALITY_PRESETS, type QualityPreset, type RenderHtmlOptions, type VideoExportOptions, type VideoOrientation, type VideoQuality, audioBitrateArg, bitrateForQuality, computeAudioTimeline, ffmpegAudioMuxArgs, ffmpegGifFilterGraph, ffmpegGifOutputArgs, ffmpegVideoQualityArgs, framesToMp4Wasm, generateRenderHtml, resolveDimensions, validateVideoExportOptions };
package/dist/index.js CHANGED
@@ -12,7 +12,27 @@ var ORIENTATION_DIMENSIONS = {
12
12
  landscape: { width: 1920, height: 1080 },
13
13
  portrait: { width: 1080, height: 1920 }
14
14
  };
15
+ function validateVideoExportOptions(options) {
16
+ if (options.fps !== void 0 && (!Number.isFinite(options.fps) || options.fps <= 0 || options.fps > 120)) {
17
+ throw new RangeError("Video FPS must be a finite number between 1 and 120.");
18
+ }
19
+ for (const [label, value] of [
20
+ ["width", options.width],
21
+ ["height", options.height]
22
+ ]) {
23
+ if (value !== void 0 && (!Number.isSafeInteger(value) || value <= 0)) {
24
+ throw new RangeError(`Video ${label} must be a positive integer.`);
25
+ }
26
+ }
27
+ if (options.quality !== void 0 && !Object.prototype.hasOwnProperty.call(QUALITY_PRESETS, options.quality)) {
28
+ throw new RangeError(`Unknown video quality: ${String(options.quality)}.`);
29
+ }
30
+ if (options.orientation !== void 0 && !Object.prototype.hasOwnProperty.call(ORIENTATION_DIMENSIONS, options.orientation)) {
31
+ throw new RangeError(`Unknown video orientation: ${String(options.orientation)}.`);
32
+ }
33
+ }
15
34
  function resolveDimensions(options) {
35
+ validateVideoExportOptions(options);
16
36
  const orientation = options.orientation ?? "landscape";
17
37
  const defaults = ORIENTATION_DIMENSIONS[orientation];
18
38
  return {
@@ -83,7 +103,15 @@ function escapeHtml(str) {
83
103
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
84
104
  }
85
105
  function generateRenderHtml(doc, options) {
86
- const { playerScript, images, audio, width = 1920, height = 1080, captionStyle } = options;
106
+ const {
107
+ playerScript,
108
+ images,
109
+ audio,
110
+ width = 1920,
111
+ height = 1080,
112
+ captionStyle,
113
+ animationsEnabled = true
114
+ } = options;
87
115
  const imageMap = {};
88
116
  if (images) {
89
117
  for (const [path, buffer] of images.entries()) {
@@ -121,13 +149,15 @@ html,body{margin:0;padding:0;width:${width}px;height:${height}px;overflow:hidden
121
149
  var doc = JSON.parse(${JSON.stringify(docJson)});
122
150
  var images = JSON.parse(${JSON.stringify(imageMapJson)});
123
151
  var audio = ${audioMapJson === "null" ? "null" : "JSON.parse(" + JSON.stringify(audioMapJson) + ")"};
124
- SquisqPlayer.mount(document.getElementById("squisq-root"), doc, {
152
+ var root = document.getElementById("squisq-root");
153
+ SquisqPlayer.mount(root, doc, {
125
154
  mode: "slideshow",
126
155
  images: images,
127
156
  audio: audio,
128
157
  autoPlay: false,
129
158
  basePath: ".",
130
- renderMode: true${captionStyle ? `,
159
+ renderMode: true,
160
+ animationsEnabled: ${JSON.stringify(animationsEnabled)}${captionStyle ? `,
131
161
  captionStyle: ${JSON.stringify(captionStyle)}` : ""}
132
162
  });
133
163
  })();
@@ -145,9 +175,51 @@ function audioBitrateArg(quality) {
145
175
  const preset = QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal;
146
176
  return `${preset.audioBitrate / 1e3}k`;
147
177
  }
178
+ function ffmpegAudioMuxArgs(bitrate) {
179
+ return ["-c:a", "aac", "-b:a", String(bitrate), "-af", "apad", "-shortest"];
180
+ }
181
+ function ffmpegGifFilterGraph(options) {
182
+ const { width, height } = options;
183
+ const maxColors = options.maxColors ?? 256;
184
+ const dither = options.dither ?? "sierra2_4a";
185
+ const bayerScale = options.bayerScale ?? 3;
186
+ for (const [label, value] of [
187
+ ["width", width],
188
+ ["height", height]
189
+ ]) {
190
+ if (!Number.isSafeInteger(value) || value <= 0) {
191
+ throw new RangeError(`GIF ${label} must be a positive integer.`);
192
+ }
193
+ }
194
+ if (!Number.isSafeInteger(maxColors) || maxColors < 2 || maxColors > 256) {
195
+ throw new RangeError("GIF maxColors must be an integer between 2 and 256.");
196
+ }
197
+ if (!["bayer", "sierra2_4a", "none"].includes(dither)) {
198
+ throw new RangeError(`Unknown GIF dither: ${String(dither)}.`);
199
+ }
200
+ if (!Number.isSafeInteger(bayerScale) || bayerScale < 0 || bayerScale > 5) {
201
+ throw new RangeError("GIF bayerScale must be an integer between 0 and 5.");
202
+ }
203
+ const ditherArgs = dither === "bayer" ? `dither=bayer:bayer_scale=${bayerScale}` : `dither=${dither}`;
204
+ return `[0:v]scale=${width}:${height}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,split[gif_frames][gif_palette_source];[gif_palette_source]palettegen=stats_mode=diff:max_colors=${maxColors}:reserve_transparent=0[gif_palette];[gif_frames][gif_palette]paletteuse=${ditherArgs}:diff_mode=rectangle[gif_out]`;
205
+ }
206
+ function ffmpegGifOutputArgs(options) {
207
+ const loop = options.loop ?? 0;
208
+ if (!Number.isSafeInteger(loop) || loop < -1 || loop > 65535) {
209
+ throw new RangeError("GIF loop must be an integer between -1 and 65535.");
210
+ }
211
+ return [
212
+ "-filter_complex",
213
+ ffmpegGifFilterGraph(options),
214
+ "-map",
215
+ "[gif_out]",
216
+ "-an",
217
+ "-loop",
218
+ String(loop)
219
+ ];
220
+ }
148
221
 
149
222
  // src/wasmEncoder.ts
150
- import { FFmpeg } from "@ffmpeg/ffmpeg";
151
223
  import { fetchFile } from "@ffmpeg/util";
152
224
  async function framesToMp4Wasm(frames, audio, options = {}) {
153
225
  const fps = options.fps ?? 30;
@@ -157,61 +229,74 @@ async function framesToMp4Wasm(frames, audio, options = {}) {
157
229
  if (frames.length === 0) {
158
230
  throw new Error("No frames provided for encoding");
159
231
  }
232
+ const runtime = globalThis;
233
+ if (runtime.process?.versions?.node && typeof window === "undefined") {
234
+ throw new Error(
235
+ "framesToMp4Wasm is browser-only. In Node.js, use framesToMp4Native or framesToMp4NativeBytes from @bendyline/squisq-cli/api."
236
+ );
237
+ }
160
238
  const duration = frames.length / fps;
239
+ const { FFmpeg } = await import("@ffmpeg/ffmpeg");
161
240
  const ffmpeg = new FFmpeg();
162
- ffmpeg.on("progress", ({ progress }) => {
163
- if (onProgress) {
164
- const percent = Math.round(progress * 100);
165
- onProgress(Math.min(percent, 99), "encoding");
241
+ try {
242
+ ffmpeg.on("progress", ({ progress }) => {
243
+ if (onProgress) {
244
+ const percent = Math.round(progress * 100);
245
+ onProgress(Math.min(percent, 99), "encoding");
246
+ }
247
+ });
248
+ await ffmpeg.load(options.ffmpegWasm);
249
+ onProgress?.(0, "writing frames");
250
+ const padLen = String(frames.length).length;
251
+ for (let i = 0; i < frames.length; i++) {
252
+ const name = `frame-${String(i + 1).padStart(padLen, "0")}.png`;
253
+ await ffmpeg.writeFile(name, frames[i]);
254
+ if (onProgress && i % 10 === 0) {
255
+ onProgress(Math.round(i / frames.length * 40), "writing frames");
256
+ }
166
257
  }
167
- });
168
- await ffmpeg.load();
169
- onProgress?.(0, "writing frames");
170
- const padLen = String(frames.length).length;
171
- for (let i = 0; i < frames.length; i++) {
172
- const name = `frame-${String(i + 1).padStart(padLen, "0")}.png`;
173
- await ffmpeg.writeFile(name, frames[i]);
174
- if (onProgress && i % 10 === 0) {
175
- onProgress(Math.round(i / frames.length * 40), "writing frames");
258
+ if (audio) {
259
+ await ffmpeg.writeFile("audio-input", audio);
176
260
  }
261
+ onProgress?.(40, "encoding");
262
+ const padPattern = `frame-%0${padLen}d.png`;
263
+ const args = ["-y", "-framerate", String(fps), "-i", padPattern];
264
+ if (audio) {
265
+ args.push("-i", "audio-input");
266
+ }
267
+ args.push(
268
+ "-c:v",
269
+ "libx264",
270
+ ...ffmpegVideoQualityArgs(quality),
271
+ "-pix_fmt",
272
+ "yuv420p",
273
+ "-vf",
274
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`
275
+ );
276
+ if (audio) {
277
+ args.push(...ffmpegAudioMuxArgs(audioBitrateArg(quality)));
278
+ }
279
+ args.push("output.mp4");
280
+ const exitCode = await ffmpeg.exec(args);
281
+ if (exitCode !== 0) {
282
+ throw new Error(`ffmpeg.wasm failed with exit code ${exitCode}`);
283
+ }
284
+ onProgress?.(95, "reading output");
285
+ const data = await ffmpeg.readFile("output.mp4");
286
+ for (let i = 0; i < frames.length; i++) {
287
+ const name = `frame-${String(i + 1).padStart(padLen, "0")}.png`;
288
+ await ffmpeg.deleteFile(name);
289
+ }
290
+ if (audio) {
291
+ await ffmpeg.deleteFile("audio-input");
292
+ }
293
+ await ffmpeg.deleteFile("output.mp4");
294
+ onProgress?.(100, "done");
295
+ const outputData = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
296
+ return { data: outputData, duration };
297
+ } finally {
298
+ ffmpeg.terminate();
177
299
  }
178
- if (audio) {
179
- await ffmpeg.writeFile("audio-input", audio);
180
- }
181
- onProgress?.(40, "encoding");
182
- const padPattern = `frame-%0${padLen}d.png`;
183
- const args = ["-y", "-framerate", String(fps), "-i", padPattern];
184
- if (audio) {
185
- args.push("-i", "audio-input");
186
- }
187
- args.push(
188
- "-c:v",
189
- "libx264",
190
- ...ffmpegVideoQualityArgs(quality),
191
- "-pix_fmt",
192
- "yuv420p",
193
- "-vf",
194
- `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`
195
- );
196
- if (audio) {
197
- args.push("-c:a", "aac", "-b:a", audioBitrateArg(quality), "-shortest");
198
- }
199
- args.push("output.mp4");
200
- await ffmpeg.exec(args);
201
- onProgress?.(95, "reading output");
202
- const data = await ffmpeg.readFile("output.mp4");
203
- for (let i = 0; i < frames.length; i++) {
204
- const name = `frame-${String(i + 1).padStart(padLen, "0")}.png`;
205
- await ffmpeg.deleteFile(name);
206
- }
207
- if (audio) {
208
- await ffmpeg.deleteFile("audio-input");
209
- }
210
- await ffmpeg.deleteFile("output.mp4");
211
- ffmpeg.terminate();
212
- onProgress?.(100, "done");
213
- const outputData = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
214
- return { data: outputData, duration };
215
300
  }
216
301
  export {
217
302
  ORIENTATION_DIMENSIONS,
@@ -220,9 +305,13 @@ export {
220
305
  bitrateForQuality,
221
306
  computeAudioTimeline,
222
307
  fetchFile,
308
+ ffmpegAudioMuxArgs,
309
+ ffmpegGifFilterGraph,
310
+ ffmpegGifOutputArgs,
223
311
  ffmpegVideoQualityArgs,
224
312
  framesToMp4Wasm,
225
313
  generateRenderHtml,
226
- resolveDimensions
314
+ resolveDimensions,
315
+ validateVideoExportOptions
227
316
  };
228
317
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts","../src/audioTimeline.ts","../src/renderHtml.ts","../src/ffmpegArgs.ts","../src/wasmEncoder.ts"],"sourcesContent":["/**\n * Video Export Types\n *\n * Shared type definitions for video encoding and render HTML generation.\n * Used by both the browser-based encoder and the CLI native encoder.\n */\n\n/**\n * Video quality preset.\n * Controls the H.264 encoding speed/quality trade-off and constant rate factor.\n *\n * - draft: ultrafast preset, CRF 28 — fast encode, lower quality (~1-2 Mbps)\n * - normal: medium preset, CRF 23 — balanced (~3-5 Mbps)\n * - high: slow preset, CRF 18 — best quality, slowest (~8-12 Mbps)\n */\nexport type VideoQuality = 'draft' | 'normal' | 'high';\n\n/** Viewport orientation for video output. */\nexport type VideoOrientation = 'landscape' | 'portrait';\n\n/** Encoding preset parameters mapped from VideoQuality. */\nexport interface QualityPreset {\n /** FFmpeg -preset value (ultrafast, medium, slow) */\n preset: string;\n /** FFmpeg -crf value (lower = higher quality, 0-51 range) */\n crf: number;\n /**\n * Bits per pixel for WebCodecs bitrate targeting.\n * Target bitrate = width * height * bitsPerPixel (see {@link bitrateForQuality}).\n * These values reproduce the historical per-quality formula exactly:\n * the old code used a `width * height * 4` baseline, halved for draft and\n * doubled for high — i.e. 2 / 4 / 8 bits per pixel.\n */\n bitsPerPixel: number;\n /** Target AAC audio bitrate in bits/sec for muxed audio tracks. */\n audioBitrate: number;\n}\n\n/** Quality preset lookup — shared between wasm and native encoders. */\nexport const QUALITY_PRESETS: Record<VideoQuality, QualityPreset> = {\n draft: { preset: 'ultrafast', crf: 28, bitsPerPixel: 2, audioBitrate: 96_000 },\n normal: { preset: 'medium', crf: 23, bitsPerPixel: 4, audioBitrate: 128_000 },\n high: { preset: 'slow', crf: 18, bitsPerPixel: 8, audioBitrate: 192_000 },\n};\n\n/**\n * Target H.264 bitrate (bits/sec) for a given quality at a given resolution.\n *\n * Computes `width * height * preset.bitsPerPixel`. With bitsPerPixel of\n * 2 / 4 / 8 (draft / normal / high) this is numerically identical to the\n * legacy formula (`width * height * 4` baseline, ×0.5 for draft, ×2 for high)\n * that previously lived in both the main-thread and worker WebCodecs encoders.\n * The single source of truth now lives here so every encode path agrees.\n */\nexport function bitrateForQuality(q: VideoQuality, width: number, height: number): number {\n const preset = QUALITY_PRESETS[q] ?? QUALITY_PRESETS.normal;\n return Math.round(width * height * preset.bitsPerPixel);\n}\n\n/** Viewport dimensions for each orientation. */\nexport const ORIENTATION_DIMENSIONS: Record<VideoOrientation, { width: number; height: number }> = {\n landscape: { width: 1920, height: 1080 },\n portrait: { width: 1080, height: 1920 },\n};\n\n/** Options for video export encoding. */\nexport interface VideoExportOptions {\n /** Frames per second (default: 30) */\n fps?: number;\n /** Video width in pixels (default: based on orientation) */\n width?: number;\n /** Video height in pixels (default: based on orientation) */\n height?: number;\n /** Encoding quality preset (default: 'normal') */\n quality?: VideoQuality;\n /** Viewport orientation (default: 'landscape') */\n orientation?: VideoOrientation;\n /**\n * Progress callback. Called during encoding with completion percentage and phase description.\n * @param percent - 0-100 completion percentage\n * @param phase - Human-readable description of current phase (e.g., 'encoding', 'muxing')\n */\n onProgress?: (percent: number, phase: string) => void;\n}\n\n/** Result from the wasm encoder. */\nexport interface EncoderResult {\n /** MP4 file bytes */\n data: Uint8Array;\n /** Video duration in seconds */\n duration: number;\n}\n\n/**\n * Resolve dimensions from options, applying orientation defaults.\n */\nexport function resolveDimensions(options: VideoExportOptions): {\n width: number;\n height: number;\n} {\n const orientation = options.orientation ?? 'landscape';\n const defaults = ORIENTATION_DIMENSIONS[orientation];\n return {\n width: options.width ?? defaults.width,\n height: options.height ?? defaults.height,\n };\n}\n","/**\n * audioTimeline — Pure scheduling of a doc's audio onto the export timeline.\n *\n * Browser-pure and Node-testable: turns a {@link Doc} into a flat list of\n * absolute-timed {@link AudioTimelineClip}s. This is the single source of\n * truth the browser MP4 export uses to place audio, and it deliberately\n * replicates the exact schedule math the CLI mix path uses so both agree:\n *\n * - Narration segments (`doc.audio.segments[]`) are laid **sequentially**,\n * each starting where the previous one ended — mirroring the CLI, which\n * concatenates the segment files in order.\n * - Timed media clips (`block.media` + `doc.documentMedia`) are placed at\n * their **absolute** doc-timeline positions via the shared\n * `resolveMediaSchedule()` helper (the same one the CLI calls), honouring\n * each clip's trim window (`sourceIn` / `absoluteEnd - absoluteStart`).\n * - Every start time is shifted by `coverPreRoll` so a cover pre-roll padding\n * (silent leading frames) keeps audio in sync.\n */\n\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { resolveMediaSchedule } from '@bendyline/squisq/schemas';\n\n/** One audio source placed on the absolute export timeline. */\nexport interface AudioTimelineClip {\n /** Source path (mp3/webm/mp4/…), relative to the doc's media dir. */\n src: string;\n /** Absolute second on the export timeline where this clip starts. */\n startSec: number;\n /** In-point within the source file to begin playback from. */\n sourceInSec: number;\n /** Played length in seconds (the trimmed window of the source). */\n durationSec: number;\n}\n\n/**\n * Flatten a doc's narration + timed-media audio into absolute-timed clips.\n *\n * Pure — depends only on `doc` (and reuses `resolveMediaSchedule` from core so\n * the browser export and the CLI mix never drift). Returns `[]` for a doc with\n * no audio at all.\n *\n * @param doc - The document to schedule audio for.\n * @param coverPreRoll - Leading silent padding (seconds) added ahead of every\n * clip, matching the cover-slide pre-roll frames. Default 0.\n */\nexport function computeAudioTimeline(doc: Doc, coverPreRoll = 0): AudioTimelineClip[] {\n const preRoll = coverPreRoll > 0 ? coverPreRoll : 0;\n const clips: AudioTimelineClip[] = [];\n\n // ── Narration: laid sequentially (matches the CLI's ordered concat). ──\n let cursor = 0;\n for (const seg of doc.audio?.segments ?? []) {\n const durationSec = Math.max(0, seg.duration);\n if (durationSec > 0 && seg.src) {\n clips.push({ src: seg.src, startSec: cursor + preRoll, sourceInSec: 0, durationSec });\n }\n cursor += durationSec;\n }\n\n // ── Timed media clips: absolute positions from the shared schedule. ──\n for (const clip of resolveMediaSchedule(doc)) {\n if (clip.kind !== 'audio') continue;\n const durationSec = Math.max(0, clip.absoluteEnd - clip.absoluteStart);\n if (durationSec <= 0 || !clip.src) continue;\n clips.push({\n src: clip.src,\n startSec: clip.absoluteStart + preRoll,\n sourceInSec: clip.sourceIn,\n durationSec,\n });\n }\n\n return clips;\n}\n","/**\n * Render HTML Generation for Video Frame Capture\n *\n * Generates a self-contained HTML document that loads the SquisqPlayer standalone\n * bundle in renderMode, embedding all images and audio as base64 data URIs.\n *\n * The generated page exposes `window.seekTo(time)`, `window.getDuration()`, etc.\n * via the SquisqRenderAPI, enabling Playwright (or any headless browser) to step\n * through frames and capture screenshots.\n *\n * Browser-pure: uses only btoa() and Uint8Array — no Node.js APIs.\n */\n\nimport type { Doc } from '@bendyline/squisq/schemas';\n\n// ── Types ──────────────────────────────────────────────────────────\n\nexport interface RenderHtmlOptions {\n /** The IIFE player bundle source code (from PLAYER_BUNDLE) */\n playerScript: string;\n\n /**\n * Map of relative image paths (as they appear in the Doc) to binary data.\n * Converted to base64 data URIs and embedded in the HTML.\n */\n images?: Map<string, ArrayBuffer>;\n\n /**\n * Map of audio segment names/paths to binary audio data.\n * Converted to base64 data URIs and embedded in the HTML.\n */\n audio?: Map<string, ArrayBuffer>;\n\n /** Viewport width in CSS pixels (default: 1920) */\n width?: number;\n\n /** Viewport height in CSS pixels (default: 1080) */\n height?: number;\n\n /** Caption style for the rendered video. Omit for no captions. */\n captionStyle?: 'standard' | 'social';\n}\n\n// ── MIME Detection ─────────────────────────────────────────────────\n\nconst MIME_MAP: Record<string, string> = {\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n png: 'image/png',\n gif: 'image/gif',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n bmp: 'image/bmp',\n avif: 'image/avif',\n mp3: 'audio/mpeg',\n wav: 'audio/wav',\n ogg: 'audio/ogg',\n mp4: 'video/mp4',\n webm: 'video/webm',\n};\n\nfunction inferMimeType(filename: string): string {\n const ext = filename.split('.').pop()?.toLowerCase() ?? '';\n return MIME_MAP[ext] ?? 'application/octet-stream';\n}\n\n// ── Base64 Encoding (browser-pure) ────────────────────────────────\n\n/**\n * Convert an ArrayBuffer to a base64 data URI.\n * Uses only standard Web APIs (Uint8Array + btoa).\n */\nfunction arrayBufferToDataUrl(buffer: ArrayBuffer, mimeType: string): string {\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return `data:${mimeType};base64,${btoa(binary)}`;\n}\n\n// ── Escaping ───────────────────────────────────────────────────────\n\n/**\n * Prevent `</script>` from prematurely closing the script tag.\n */\nfunction escapeForScript(str: string): string {\n return str.replace(/<\\/(script)/gi, '<\\\\/$1');\n}\n\n/** Escape HTML special characters. */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;');\n}\n\n// ── HTML Generation ────────────────────────────────────────────────\n\n/**\n * Generate a self-contained HTML document for headless video frame capture.\n *\n * The page mounts the SquisqPlayer in renderMode, which exposes the\n * SquisqRenderAPI on `window` (seekTo, getDuration, getCaptions, etc.).\n *\n * @param doc - The Doc to render\n * @param options - Render HTML options including player script and media\n * @returns Complete HTML string ready to be loaded in a headless browser\n */\nexport function generateRenderHtml(doc: Doc, options: RenderHtmlOptions): string {\n const { playerScript, images, audio, width = 1920, height = 1080, captionStyle } = options;\n\n // Build base64 image map\n const imageMap: Record<string, string> = {};\n if (images) {\n for (const [path, buffer] of images.entries()) {\n imageMap[path] = arrayBufferToDataUrl(buffer, inferMimeType(path));\n }\n }\n\n // Build base64 audio map\n const audioMap: Record<string, string> = {};\n let hasAudio = false;\n if (audio) {\n for (const [name, buffer] of audio.entries()) {\n audioMap[name] = arrayBufferToDataUrl(buffer, inferMimeType(name));\n hasAudio = true;\n }\n }\n\n const docJson = escapeForScript(JSON.stringify(doc));\n const imageMapJson = escapeForScript(JSON.stringify(imageMap));\n const audioMapJson = hasAudio ? escapeForScript(JSON.stringify(audioMap)) : 'null';\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=${width}, height=${height}\">\n<title>${escapeHtml('Squisq Video Render')}</title>\n<style>\n*,*::before,*::after{box-sizing:border-box}\nhtml,body{margin:0;padding:0;width:${width}px;height:${height}px;overflow:hidden;background:#000}\n#squisq-root{width:${width}px;height:${height}px;display:flex;align-items:center;justify-content:center}\n</style>\n</head>\n<body>\n<div id=\"squisq-root\"></div>\n<script>${escapeForScript(playerScript)}</script>\n<script>\n(function(){\n var doc = JSON.parse(${JSON.stringify(docJson)});\n var images = JSON.parse(${JSON.stringify(imageMapJson)});\n var audio = ${audioMapJson === 'null' ? 'null' : 'JSON.parse(' + JSON.stringify(audioMapJson) + ')'};\n SquisqPlayer.mount(document.getElementById(\"squisq-root\"), doc, {\n mode: \"slideshow\",\n images: images,\n audio: audio,\n autoPlay: false,\n basePath: \".\",\n renderMode: true${captionStyle ? `,\\n captionStyle: ${JSON.stringify(captionStyle)}` : ''}\n });\n})();\n</script>\n</body>\n</html>`;\n}\n","/**\n * FFmpeg argument builders — the single source of truth for translating a\n * {@link VideoQuality} into ffmpeg CLI flags. Shared verbatim by every\n * ffmpeg-based encode path: the wasm encoder ({@link ./wasmEncoder}), the\n * video-react fallback worker, and the CLI native encoder. Deriving these\n * from {@link QUALITY_PRESETS} keeps the browser and CLI byte-for-byte aligned.\n *\n * Pure, dependency-free, and unit-testable in isolation (the actual ffmpeg\n * invocations live behind wasm/child-process boundaries that are awkward to\n * exercise directly).\n */\n\nimport { QUALITY_PRESETS, type VideoQuality } from './types.js';\n\n/**\n * H.264 speed/quality flags (`-preset`, `-crf`) for a quality level.\n * @example ffmpegVideoQualityArgs('high') // ['-preset', 'slow', '-crf', '18']\n */\nexport function ffmpegVideoQualityArgs(quality: VideoQuality): string[] {\n const preset = QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal;\n return ['-preset', preset.preset, '-crf', String(preset.crf)];\n}\n\n/**\n * AAC audio-bitrate flag value in ffmpeg's `k` shorthand for a quality level.\n * @example audioBitrateArg('high') // '192k'\n */\nexport function audioBitrateArg(quality: VideoQuality): string {\n const preset = QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal;\n return `${preset.audioBitrate / 1000}k`;\n}\n","/**\n * WASM Video Encoder\n *\n * Encodes PNG frame screenshots into an MP4 video using ffmpeg.wasm.\n * Browser-pure — no Node.js APIs. Works in any environment with SharedArrayBuffer\n * support (browsers with COOP/COEP headers, or Node 18+).\n *\n * Uses @ffmpeg/ffmpeg for H.264 encoding and optional AAC audio muxing.\n */\n\nimport { FFmpeg } from '@ffmpeg/ffmpeg';\nimport { fetchFile } from '@ffmpeg/util';\n\nimport type { VideoExportOptions, EncoderResult } from './types.js';\nimport { resolveDimensions } from './types.js';\nimport { ffmpegVideoQualityArgs, audioBitrateArg } from './ffmpegArgs.js';\n\n/**\n * Encode an array of PNG frame screenshots into an MP4 video.\n *\n * @param frames - Array of PNG image bytes (one per frame, in order)\n * @param audio - Optional WAV/MP3/AAC audio bytes to mux into the video\n * @param options - Encoding options (fps, quality, dimensions, progress)\n * @returns Encoded MP4 data and duration metadata\n */\nexport async function framesToMp4Wasm(\n frames: Uint8Array[],\n audio: Uint8Array | null,\n options: VideoExportOptions = {},\n): Promise<EncoderResult> {\n const fps = options.fps ?? 30;\n const quality = options.quality ?? 'normal';\n const { width, height } = resolveDimensions(options);\n const onProgress = options.onProgress;\n\n if (frames.length === 0) {\n throw new Error('No frames provided for encoding');\n }\n\n const duration = frames.length / fps;\n\n // Initialize ffmpeg.wasm\n const ffmpeg = new FFmpeg();\n\n ffmpeg.on('progress', ({ progress }) => {\n if (onProgress) {\n const percent = Math.round(progress * 100);\n onProgress(Math.min(percent, 99), 'encoding');\n }\n });\n\n await ffmpeg.load();\n\n onProgress?.(0, 'writing frames');\n\n // Write frame PNGs to virtual filesystem\n const padLen = String(frames.length).length;\n for (let i = 0; i < frames.length; i++) {\n const name = `frame-${String(i + 1).padStart(padLen, '0')}.png`;\n await ffmpeg.writeFile(name, frames[i]);\n\n // Report frame-write progress (0-40% of total)\n if (onProgress && i % 10 === 0) {\n onProgress(Math.round((i / frames.length) * 40), 'writing frames');\n }\n }\n\n // Write audio if provided\n if (audio) {\n await ffmpeg.writeFile('audio-input', audio);\n }\n\n onProgress?.(40, 'encoding');\n\n // Build ffmpeg command\n const padPattern = `frame-%0${padLen}d.png`;\n const args = ['-y', '-framerate', String(fps), '-i', padPattern];\n\n // Add audio input\n if (audio) {\n args.push('-i', 'audio-input');\n }\n\n // Video encoding settings\n args.push(\n '-c:v',\n 'libx264',\n ...ffmpegVideoQualityArgs(quality),\n '-pix_fmt',\n 'yuv420p',\n '-vf',\n `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`,\n );\n\n // Audio encoding\n if (audio) {\n args.push('-c:a', 'aac', '-b:a', audioBitrateArg(quality), '-shortest');\n }\n\n args.push('output.mp4');\n\n // Run encoding\n await ffmpeg.exec(args);\n\n onProgress?.(95, 'reading output');\n\n // Read the output file\n const data = await ffmpeg.readFile('output.mp4');\n\n // Cleanup virtual filesystem\n for (let i = 0; i < frames.length; i++) {\n const name = `frame-${String(i + 1).padStart(padLen, '0')}.png`;\n await ffmpeg.deleteFile(name);\n }\n if (audio) {\n await ffmpeg.deleteFile('audio-input');\n }\n await ffmpeg.deleteFile('output.mp4');\n\n ffmpeg.terminate();\n\n onProgress?.(100, 'done');\n\n // ffmpeg.readFile returns Uint8Array for binary files\n const outputData = data instanceof Uint8Array ? data : new TextEncoder().encode(data as string);\n\n return { data: outputData, duration };\n}\n\n// Re-export fetchFile for convenience — consumers may need it to prepare audio bytes\nexport { fetchFile };\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/types.ts","../src/audioTimeline.ts","../src/renderHtml.ts","../src/ffmpegArgs.ts","../src/wasmEncoder.ts"],"sourcesContent":["/**\n * Video Export Types\n *\n * Shared type definitions for video encoding and render HTML generation.\n * Used by both the browser-based encoder and the CLI native encoder.\n */\n\n/**\n * Video quality preset.\n * Controls the H.264 encoding speed/quality trade-off and constant rate factor.\n *\n * - draft: ultrafast preset, CRF 28 — fast encode, lower quality (~1-2 Mbps)\n * - normal: medium preset, CRF 23 — balanced (~3-5 Mbps)\n * - high: slow preset, CRF 18 — best quality, slowest (~8-12 Mbps)\n */\nexport type VideoQuality = 'draft' | 'normal' | 'high';\n\n/** Viewport orientation for video output. */\nexport type VideoOrientation = 'landscape' | 'portrait';\n\n/** Optional URLs for self-hosting or pinning ffmpeg.wasm runtime assets. */\nexport interface FfmpegWasmLoadConfig {\n coreURL?: string;\n wasmURL?: string;\n workerURL?: string;\n classWorkerURL?: string;\n}\n\n/** Encoding preset parameters mapped from VideoQuality. */\nexport interface QualityPreset {\n /** FFmpeg -preset value (ultrafast, medium, slow) */\n preset: string;\n /** FFmpeg -crf value (lower = higher quality, 0-51 range) */\n crf: number;\n /**\n * Bits per pixel for WebCodecs bitrate targeting.\n * Target bitrate = width * height * bitsPerPixel (see {@link bitrateForQuality}).\n * These values reproduce the historical per-quality formula exactly:\n * the old code used a `width * height * 4` baseline, halved for draft and\n * doubled for high — i.e. 2 / 4 / 8 bits per pixel.\n */\n bitsPerPixel: number;\n /** Target AAC audio bitrate in bits/sec for muxed audio tracks. */\n audioBitrate: number;\n}\n\n/** Quality preset lookup — shared between wasm and native encoders. */\nexport const QUALITY_PRESETS: Record<VideoQuality, QualityPreset> = {\n draft: { preset: 'ultrafast', crf: 28, bitsPerPixel: 2, audioBitrate: 96_000 },\n normal: { preset: 'medium', crf: 23, bitsPerPixel: 4, audioBitrate: 128_000 },\n high: { preset: 'slow', crf: 18, bitsPerPixel: 8, audioBitrate: 192_000 },\n};\n\n/**\n * Target H.264 bitrate (bits/sec) for a given quality at a given resolution.\n *\n * Computes `width * height * preset.bitsPerPixel`. With bitsPerPixel of\n * 2 / 4 / 8 (draft / normal / high) this is numerically identical to the\n * legacy formula (`width * height * 4` baseline, ×0.5 for draft, ×2 for high)\n * that previously lived in both the main-thread and worker WebCodecs encoders.\n * The single source of truth now lives here so every encode path agrees.\n */\nexport function bitrateForQuality(q: VideoQuality, width: number, height: number): number {\n const preset = QUALITY_PRESETS[q] ?? QUALITY_PRESETS.normal;\n return Math.round(width * height * preset.bitsPerPixel);\n}\n\n/** Viewport dimensions for each orientation. */\nexport const ORIENTATION_DIMENSIONS: Record<VideoOrientation, { width: number; height: number }> = {\n landscape: { width: 1920, height: 1080 },\n portrait: { width: 1080, height: 1920 },\n};\n\n/** Options for video export encoding. */\nexport interface VideoExportOptions {\n /** Frames per second (default: 30) */\n fps?: number;\n /** Video width in pixels (default: based on orientation) */\n width?: number;\n /** Video height in pixels (default: based on orientation) */\n height?: number;\n /** Encoding quality preset (default: 'normal') */\n quality?: VideoQuality;\n /** Viewport orientation (default: 'landscape') */\n orientation?: VideoOrientation;\n /** Optional self-hosted ffmpeg.wasm assets for offline/CSP-controlled use. */\n ffmpegWasm?: FfmpegWasmLoadConfig;\n /**\n * Progress callback. Called during encoding with completion percentage and phase description.\n * @param percent - 0-100 completion percentage\n * @param phase - Human-readable description of current phase (e.g., 'encoding', 'muxing')\n */\n onProgress?: (percent: number, phase: string) => void;\n}\n\n/** Result from the wasm encoder. */\nexport interface EncoderResult {\n /** MP4 file bytes */\n data: Uint8Array;\n /** Video duration in seconds */\n duration: number;\n}\n\n/** Fail fast with actionable errors before launching an encoder or browser. */\nexport function validateVideoExportOptions(options: VideoExportOptions): void {\n if (\n options.fps !== undefined &&\n (!Number.isFinite(options.fps) || options.fps <= 0 || options.fps > 120)\n ) {\n throw new RangeError('Video FPS must be a finite number between 1 and 120.');\n }\n for (const [label, value] of [\n ['width', options.width],\n ['height', options.height],\n ] as const) {\n if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {\n throw new RangeError(`Video ${label} must be a positive integer.`);\n }\n }\n if (\n options.quality !== undefined &&\n !Object.prototype.hasOwnProperty.call(QUALITY_PRESETS, options.quality)\n ) {\n throw new RangeError(`Unknown video quality: ${String(options.quality)}.`);\n }\n if (\n options.orientation !== undefined &&\n !Object.prototype.hasOwnProperty.call(ORIENTATION_DIMENSIONS, options.orientation)\n ) {\n throw new RangeError(`Unknown video orientation: ${String(options.orientation)}.`);\n }\n}\n\n/**\n * Resolve dimensions from options, applying orientation defaults.\n */\nexport function resolveDimensions(options: VideoExportOptions): {\n width: number;\n height: number;\n} {\n validateVideoExportOptions(options);\n const orientation = options.orientation ?? 'landscape';\n const defaults = ORIENTATION_DIMENSIONS[orientation];\n return {\n width: options.width ?? defaults.width,\n height: options.height ?? defaults.height,\n };\n}\n","/**\n * audioTimeline — Pure scheduling of a doc's audio onto the export timeline.\n *\n * Browser-pure and Node-testable: turns a {@link Doc} into a flat list of\n * absolute-timed {@link AudioTimelineClip}s. This is the single source of\n * truth the browser MP4 export uses to place audio, and it deliberately\n * replicates the exact schedule math the CLI mix path uses so both agree:\n *\n * - Narration segments (`doc.audio.segments[]`) are laid **sequentially**,\n * each starting where the previous one ended — mirroring the CLI, which\n * concatenates the segment files in order.\n * - Timed media clips (`block.media` + `doc.documentMedia`) are placed at\n * their **absolute** doc-timeline positions via the shared\n * `resolveMediaSchedule()` helper (the same one the CLI calls), honouring\n * each clip's trim window (`sourceIn` / `absoluteEnd - absoluteStart`).\n * - Every start time is shifted by `coverPreRoll` so a cover pre-roll padding\n * (silent leading frames) keeps audio in sync.\n */\n\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport { resolveMediaSchedule } from '@bendyline/squisq/schemas';\n\n/** One audio source placed on the absolute export timeline. */\nexport interface AudioTimelineClip {\n /** Source path (mp3/webm/mp4/…), relative to the doc's media dir. */\n src: string;\n /** Absolute second on the export timeline where this clip starts. */\n startSec: number;\n /** In-point within the source file to begin playback from. */\n sourceInSec: number;\n /** Played length in seconds (the trimmed window of the source). */\n durationSec: number;\n}\n\n/**\n * Flatten a doc's narration + timed-media audio into absolute-timed clips.\n *\n * Pure — depends only on `doc` (and reuses `resolveMediaSchedule` from core so\n * the browser export and the CLI mix never drift). Returns `[]` for a doc with\n * no audio at all.\n *\n * @param doc - The document to schedule audio for.\n * @param coverPreRoll - Leading silent padding (seconds) added ahead of every\n * clip, matching the cover-slide pre-roll frames. Default 0.\n */\nexport function computeAudioTimeline(doc: Doc, coverPreRoll = 0): AudioTimelineClip[] {\n const preRoll = coverPreRoll > 0 ? coverPreRoll : 0;\n const clips: AudioTimelineClip[] = [];\n\n // ── Narration: laid sequentially (matches the CLI's ordered concat). ──\n let cursor = 0;\n for (const seg of doc.audio?.segments ?? []) {\n const durationSec = Math.max(0, seg.duration);\n if (durationSec > 0 && seg.src) {\n clips.push({ src: seg.src, startSec: cursor + preRoll, sourceInSec: 0, durationSec });\n }\n cursor += durationSec;\n }\n\n // ── Timed media clips: absolute positions from the shared schedule. ──\n for (const clip of resolveMediaSchedule(doc)) {\n if (clip.kind !== 'audio') continue;\n const durationSec = Math.max(0, clip.absoluteEnd - clip.absoluteStart);\n if (durationSec <= 0 || !clip.src) continue;\n clips.push({\n src: clip.src,\n startSec: clip.absoluteStart + preRoll,\n sourceInSec: clip.sourceIn,\n durationSec,\n });\n }\n\n return clips;\n}\n","/**\n * Render HTML Generation for Video Frame Capture\n *\n * Generates a self-contained HTML document that loads the SquisqPlayer standalone\n * bundle in renderMode, embedding all images and audio as base64 data URIs.\n *\n * The generated page mounts one standalone player whose instance handle is\n * available through `SquisqPlayer.getHandle(root)`. Headless callers use that\n * handle's render API to step through frames and capture screenshots.\n *\n * Browser-pure: uses only btoa() and Uint8Array — no Node.js APIs.\n */\n\nimport type { Doc } from '@bendyline/squisq/schemas';\n\n// ── Types ──────────────────────────────────────────────────────────\n\nexport interface RenderHtmlOptions {\n /** The IIFE player bundle source code (from PLAYER_BUNDLE) */\n playerScript: string;\n\n /**\n * Map of relative image paths (as they appear in the Doc) to binary data.\n * Converted to base64 data URIs and embedded in the HTML.\n */\n images?: Map<string, ArrayBuffer>;\n\n /**\n * Map of audio segment names/paths to binary audio data.\n * Converted to base64 data URIs and embedded in the HTML.\n */\n audio?: Map<string, ArrayBuffer>;\n\n /** Viewport width in CSS pixels (default: 1920) */\n width?: number;\n\n /** Viewport height in CSS pixels (default: 1080) */\n height?: number;\n\n /** Caption style for the rendered video. Omit for no captions. */\n captionStyle?: 'standard' | 'social';\n\n /**\n * Whether Squisq layer animations and block transitions are rendered.\n * Defaults to true. Embedded/timed media and document timing are unaffected.\n */\n animationsEnabled?: boolean;\n}\n\n// ── MIME Detection ─────────────────────────────────────────────────\n\nconst MIME_MAP: Record<string, string> = {\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n png: 'image/png',\n gif: 'image/gif',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n bmp: 'image/bmp',\n avif: 'image/avif',\n mp3: 'audio/mpeg',\n wav: 'audio/wav',\n ogg: 'audio/ogg',\n mp4: 'video/mp4',\n webm: 'video/webm',\n};\n\nfunction inferMimeType(filename: string): string {\n const ext = filename.split('.').pop()?.toLowerCase() ?? '';\n return MIME_MAP[ext] ?? 'application/octet-stream';\n}\n\n// ── Base64 Encoding (browser-pure) ────────────────────────────────\n\n/**\n * Convert an ArrayBuffer to a base64 data URI.\n * Uses only standard Web APIs (Uint8Array + btoa).\n */\nfunction arrayBufferToDataUrl(buffer: ArrayBuffer, mimeType: string): string {\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return `data:${mimeType};base64,${btoa(binary)}`;\n}\n\n// ── Escaping ───────────────────────────────────────────────────────\n\n/**\n * Prevent `</script>` from prematurely closing the script tag.\n */\nfunction escapeForScript(str: string): string {\n return str.replace(/<\\/(script)/gi, '<\\\\/$1');\n}\n\n/** Escape HTML special characters. */\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;');\n}\n\n// ── HTML Generation ────────────────────────────────────────────────\n\n/**\n * Generate a self-contained HTML document for headless video frame capture.\n *\n * The page mounts the SquisqPlayer in renderMode. Call\n * `SquisqPlayer.getHandle(root)` to obtain this instance's render API.\n *\n * @param doc - The Doc to render\n * @param options - Render HTML options including player script and media\n * @returns Complete HTML string ready to be loaded in a headless browser\n */\nexport function generateRenderHtml(doc: Doc, options: RenderHtmlOptions): string {\n const {\n playerScript,\n images,\n audio,\n width = 1920,\n height = 1080,\n captionStyle,\n animationsEnabled = true,\n } = options;\n\n // Build base64 image map\n const imageMap: Record<string, string> = {};\n if (images) {\n for (const [path, buffer] of images.entries()) {\n imageMap[path] = arrayBufferToDataUrl(buffer, inferMimeType(path));\n }\n }\n\n // Build base64 audio map\n const audioMap: Record<string, string> = {};\n let hasAudio = false;\n if (audio) {\n for (const [name, buffer] of audio.entries()) {\n audioMap[name] = arrayBufferToDataUrl(buffer, inferMimeType(name));\n hasAudio = true;\n }\n }\n\n const docJson = escapeForScript(JSON.stringify(doc));\n const imageMapJson = escapeForScript(JSON.stringify(imageMap));\n const audioMapJson = hasAudio ? escapeForScript(JSON.stringify(audioMap)) : 'null';\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=${width}, height=${height}\">\n<title>${escapeHtml('Squisq Video Render')}</title>\n<style>\n*,*::before,*::after{box-sizing:border-box}\nhtml,body{margin:0;padding:0;width:${width}px;height:${height}px;overflow:hidden;background:#000}\n#squisq-root{width:${width}px;height:${height}px;display:flex;align-items:center;justify-content:center}\n</style>\n</head>\n<body>\n<div id=\"squisq-root\"></div>\n<script>${escapeForScript(playerScript)}</script>\n<script>\n(function(){\n var doc = JSON.parse(${JSON.stringify(docJson)});\n var images = JSON.parse(${JSON.stringify(imageMapJson)});\n var audio = ${audioMapJson === 'null' ? 'null' : 'JSON.parse(' + JSON.stringify(audioMapJson) + ')'};\n var root = document.getElementById(\"squisq-root\");\n SquisqPlayer.mount(root, doc, {\n mode: \"slideshow\",\n images: images,\n audio: audio,\n autoPlay: false,\n basePath: \".\",\n renderMode: true,\n animationsEnabled: ${JSON.stringify(animationsEnabled)}${captionStyle ? `,\\n captionStyle: ${JSON.stringify(captionStyle)}` : ''}\n });\n})();\n</script>\n</body>\n</html>`;\n}\n","/**\n * FFmpeg argument builders — the single source of truth for translating a\n * {@link VideoQuality} into ffmpeg CLI flags. Shared verbatim by every\n * ffmpeg-based encode path: the wasm encoder ({@link ./wasmEncoder}), the\n * video-react fallback worker, and the CLI native encoder. Deriving these\n * from {@link QUALITY_PRESETS} keeps the browser and CLI byte-for-byte aligned.\n *\n * Pure, dependency-free, and unit-testable in isolation (the actual ffmpeg\n * invocations live behind wasm/child-process boundaries that are awkward to\n * exercise directly).\n */\n\nimport { QUALITY_PRESETS, type VideoQuality } from './types.js';\n\n/** Dithering algorithms intentionally exposed by Squisq's GIF encoder. */\nexport type GifDither = 'bayer' | 'sierra2_4a' | 'none';\n\n/** Options for the shared palette-based animated GIF filter graph. */\nexport interface GifFilterOptions {\n width: number;\n height: number;\n /** Palette size, from 2 through GIF's maximum of 256. */\n maxColors?: number;\n /** Palette dithering algorithm (default: `sierra2_4a`). */\n dither?: GifDither;\n /** Ordered Bayer strength, used only when `dither` is `bayer` (0-5). */\n bayerScale?: number;\n}\n\n/** Options for GIF muxing in addition to the palette filter graph. */\nexport interface GifOutputOptions extends GifFilterOptions {\n /** Number of repeats; 0 loops forever and -1 disables looping. */\n loop?: number;\n}\n\n/**\n * H.264 speed/quality flags (`-preset`, `-crf`) for a quality level.\n * @example ffmpegVideoQualityArgs('high') // ['-preset', 'slow', '-crf', '18']\n */\nexport function ffmpegVideoQualityArgs(quality: VideoQuality): string[] {\n const preset = QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal;\n return ['-preset', preset.preset, '-crf', String(preset.crf)];\n}\n\n/**\n * AAC audio-bitrate flag value in ffmpeg's `k` shorthand for a quality level.\n * @example audioBitrateArg('high') // '192k'\n */\nexport function audioBitrateArg(quality: VideoQuality): string {\n const preset = QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal;\n return `${preset.audioBitrate / 1000}k`;\n}\n\n/**\n * AAC muxing flags that preserve the complete video timeline.\n *\n * `-shortest` by itself truncates video whenever narration ends early. Padding\n * the audio stream first makes the video stream the shortest input instead, so\n * audio longer than the video is trimmed while shorter audio becomes silence.\n */\nexport function ffmpegAudioMuxArgs(bitrate: string | number): string[] {\n return ['-c:a', 'aac', '-b:a', String(bitrate), '-af', 'apad', '-shortest'];\n}\n\n/**\n * Build a high-quality, compression-friendly GIF palette filter graph.\n *\n * A single palette is derived from the parts of the frame that change, while\n * `diff_mode=rectangle` keeps error diffusion inside the changed rectangle so\n * static slide backgrounds remain byte-stable and compress efficiently.\n */\nexport function ffmpegGifFilterGraph(options: GifFilterOptions): string {\n const { width, height } = options;\n const maxColors = options.maxColors ?? 256;\n const dither = options.dither ?? 'sierra2_4a';\n const bayerScale = options.bayerScale ?? 3;\n\n for (const [label, value] of [\n ['width', width],\n ['height', height],\n ] as const) {\n if (!Number.isSafeInteger(value) || value <= 0) {\n throw new RangeError(`GIF ${label} must be a positive integer.`);\n }\n }\n if (!Number.isSafeInteger(maxColors) || maxColors < 2 || maxColors > 256) {\n throw new RangeError('GIF maxColors must be an integer between 2 and 256.');\n }\n if (!['bayer', 'sierra2_4a', 'none'].includes(dither)) {\n throw new RangeError(`Unknown GIF dither: ${String(dither)}.`);\n }\n if (!Number.isSafeInteger(bayerScale) || bayerScale < 0 || bayerScale > 5) {\n throw new RangeError('GIF bayerScale must be an integer between 0 and 5.');\n }\n\n const ditherArgs =\n dither === 'bayer' ? `dither=bayer:bayer_scale=${bayerScale}` : `dither=${dither}`;\n return (\n `[0:v]scale=${width}:${height}:force_original_aspect_ratio=decrease:flags=lanczos,` +\n `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,split[gif_frames][gif_palette_source];` +\n `[gif_palette_source]palettegen=stats_mode=diff:max_colors=${maxColors}:reserve_transparent=0[gif_palette];` +\n `[gif_frames][gif_palette]paletteuse=${ditherArgs}:diff_mode=rectangle[gif_out]`\n );\n}\n\n/** Build the output-side FFmpeg arguments for an animated GIF. */\nexport function ffmpegGifOutputArgs(options: GifOutputOptions): string[] {\n const loop = options.loop ?? 0;\n if (!Number.isSafeInteger(loop) || loop < -1 || loop > 65_535) {\n throw new RangeError('GIF loop must be an integer between -1 and 65535.');\n }\n return [\n '-filter_complex',\n ffmpegGifFilterGraph(options),\n '-map',\n '[gif_out]',\n '-an',\n '-loop',\n String(loop),\n ];\n}\n","/**\n * WASM Video Encoder\n *\n * Encodes PNG frame screenshots into an MP4 video using ffmpeg.wasm.\n * Browser-pure — no Node.js APIs. The encoder requires a browser runtime with\n * SharedArrayBuffer support (normally supplied via COOP/COEP headers).\n *\n * Uses @ffmpeg/ffmpeg for H.264 encoding and optional AAC audio muxing.\n */\n\nimport { fetchFile } from '@ffmpeg/util';\n\nimport type { VideoExportOptions, EncoderResult } from './types.js';\nimport { resolveDimensions } from './types.js';\nimport { ffmpegVideoQualityArgs, audioBitrateArg, ffmpegAudioMuxArgs } from './ffmpegArgs.js';\n\n/**\n * Encode an array of PNG frame screenshots into an MP4 video.\n *\n * @param frames - Array of PNG image bytes (one per frame, in order)\n * @param audio - Optional WAV/MP3/AAC audio bytes to mux into the video\n * @param options - Encoding options (fps, quality, dimensions, progress)\n * @returns Encoded MP4 data and duration metadata\n */\nexport async function framesToMp4Wasm(\n frames: Uint8Array[],\n audio: Uint8Array | null,\n options: VideoExportOptions = {},\n): Promise<EncoderResult> {\n const fps = options.fps ?? 30;\n const quality = options.quality ?? 'normal';\n const { width, height } = resolveDimensions(options);\n const onProgress = options.onProgress;\n\n if (frames.length === 0) {\n throw new Error('No frames provided for encoding');\n }\n\n const runtime = globalThis as typeof globalThis & {\n process?: { versions?: { node?: string } };\n };\n if (runtime.process?.versions?.node && typeof window === 'undefined') {\n throw new Error(\n 'framesToMp4Wasm is browser-only. In Node.js, use framesToMp4Native or framesToMp4NativeBytes from @bendyline/squisq-cli/api.',\n );\n }\n\n const duration = frames.length / fps;\n\n // Initialize ffmpeg.wasm\n const { FFmpeg } = await import('@ffmpeg/ffmpeg');\n const ffmpeg = new FFmpeg();\n try {\n ffmpeg.on('progress', ({ progress }) => {\n if (onProgress) {\n const percent = Math.round(progress * 100);\n onProgress(Math.min(percent, 99), 'encoding');\n }\n });\n\n await ffmpeg.load(options.ffmpegWasm);\n\n onProgress?.(0, 'writing frames');\n\n // Write frame PNGs to virtual filesystem\n const padLen = String(frames.length).length;\n for (let i = 0; i < frames.length; i++) {\n const name = `frame-${String(i + 1).padStart(padLen, '0')}.png`;\n await ffmpeg.writeFile(name, frames[i]);\n\n // Report frame-write progress (0-40% of total)\n if (onProgress && i % 10 === 0) {\n onProgress(Math.round((i / frames.length) * 40), 'writing frames');\n }\n }\n\n // Write audio if provided\n if (audio) {\n await ffmpeg.writeFile('audio-input', audio);\n }\n\n onProgress?.(40, 'encoding');\n\n // Build ffmpeg command\n const padPattern = `frame-%0${padLen}d.png`;\n const args = ['-y', '-framerate', String(fps), '-i', padPattern];\n\n // Add audio input\n if (audio) {\n args.push('-i', 'audio-input');\n }\n\n // Video encoding settings\n args.push(\n '-c:v',\n 'libx264',\n ...ffmpegVideoQualityArgs(quality),\n '-pix_fmt',\n 'yuv420p',\n '-vf',\n `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`,\n );\n\n // Audio encoding\n if (audio) {\n args.push(...ffmpegAudioMuxArgs(audioBitrateArg(quality)));\n }\n\n args.push('output.mp4');\n\n // Run encoding\n const exitCode = await ffmpeg.exec(args);\n if (exitCode !== 0) {\n throw new Error(`ffmpeg.wasm failed with exit code ${exitCode}`);\n }\n\n onProgress?.(95, 'reading output');\n\n // Read the output file\n const data = await ffmpeg.readFile('output.mp4');\n\n // Cleanup virtual filesystem\n for (let i = 0; i < frames.length; i++) {\n const name = `frame-${String(i + 1).padStart(padLen, '0')}.png`;\n await ffmpeg.deleteFile(name);\n }\n if (audio) {\n await ffmpeg.deleteFile('audio-input');\n }\n await ffmpeg.deleteFile('output.mp4');\n\n onProgress?.(100, 'done');\n\n // ffmpeg.readFile returns Uint8Array for binary files\n const outputData = data instanceof Uint8Array ? data : new TextEncoder().encode(data as string);\n\n return { data: outputData, duration };\n } finally {\n ffmpeg.terminate();\n }\n}\n\n// Re-export fetchFile for convenience — consumers may need it to prepare audio bytes\nexport { fetchFile };\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-video",
3
- "version": "1.2.1",
4
- "description": "Video encoding and render HTML generation for Squisq documents — browser-pure, works in Node and browser",
3
+ "version": "2.0.0",
4
+ "description": "Cross-runtime video and animated-GIF helpers with browser-based ffmpeg.wasm encoding for Squisq documents",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
7
7
  "repository": {
@@ -14,6 +14,7 @@
14
14
  "squisq",
15
15
  "video",
16
16
  "mp4",
17
+ "gif",
17
18
  "ffmpeg",
18
19
  "wasm",
19
20
  "export"
@@ -40,7 +41,7 @@
40
41
  "typecheck": "tsc --noEmit"
41
42
  },
42
43
  "dependencies": {
43
- "@bendyline/squisq": "1.5.1",
44
+ "@bendyline/squisq": "2.0.0",
44
45
  "@ffmpeg/ffmpeg": "0.12.15",
45
46
  "@ffmpeg/util": "0.12.2"
46
47
  },