@bendyline/squisq-video 1.2.0 → 1.2.2

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
@@ -15,29 +15,36 @@ npm install @bendyline/squisq-video
15
15
 
16
16
  ## What's Inside
17
17
 
18
- | Module | Description |
19
- | --------------- | ---------------------------------------------------------------------- |
20
- | **renderHtml** | Generate self-contained HTML with embedded media for headless capture |
21
- | **wasmEncoder** | Encode PNG frames + audio to MP4 using ffmpeg.wasm |
22
- | **types** | Quality presets, orientation dimensions, and shared video export types |
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 |
27
+
28
+ 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).
23
29
 
24
30
  ## Quick Start
25
31
 
26
32
  ### Generate Render HTML
27
33
 
28
- Create a self-contained HTML page that mounts a Squisq player with a seekable API suitable for headless frame capture with Playwright or Puppeteer:
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:
29
35
 
30
36
  ```ts
31
37
  import { generateRenderHtml } from '@bendyline/squisq-video';
32
-
33
- const html = generateRenderHtml({
34
- doc,
35
- playerScript, // URL or inline script for squisq-player.global.js
36
- images, // Map<string, ArrayBuffer> of embedded images
37
- audio, // Map<string, ArrayBuffer> of audio segments
38
+ import { PLAYER_BUNDLE } from '@bendyline/squisq-react/standalone-source';
39
+
40
+ const html = generateRenderHtml(doc, {
41
+ playerScript: PLAYER_BUNDLE, // the IIFE player bundle source string
42
+ images, // Map<string, ArrayBuffer> embedded as data URIs
43
+ audio, // Map<string, ArrayBuffer> embedded as data URIs
44
+ width: 1920, // default 1920
45
+ height: 1080, // default 1080
46
+ captionStyle: 'standard', // 'standard' | 'social' — omit for no captions
38
47
  });
39
-
40
- // The rendered page exposes window.seekTo(time) and window.getDuration()
41
48
  ```
42
49
 
43
50
  ### Encode Frames to MP4
@@ -45,24 +52,42 @@ const html = generateRenderHtml({
45
52
  ```ts
46
53
  import { framesToMp4Wasm } from '@bendyline/squisq-video';
47
54
 
48
- const mp4Bytes = await framesToMp4Wasm({
49
- frames, // Uint8Array[] of PNG frame data
50
- audio, // optional audio ArrayBuffer
51
- fps: 30,
52
- width: 1920,
53
- height: 1080,
54
- quality: 'normal',
55
- onProgress: (pct) => console.log(`${pct}% done`),
56
- });
55
+ const { data, duration } = await framesToMp4Wasm(
56
+ frames, // Uint8Array[] PNG frame bytes, in order
57
+ audioBytes, // Uint8Array | null — optional WAV/MP3/AAC track (muxed as AAC)
58
+ {
59
+ fps: 30, // default 30
60
+ quality: 'normal', // 'draft' | 'normal' | 'high' (default 'normal')
61
+ orientation: 'landscape', // 'landscape' | 'portrait' (default 'landscape')
62
+ // width / height override the orientation defaults
63
+ onProgress: (percent, phase) => console.log(`${phase}: ${percent}%`),
64
+ },
65
+ );
66
+ // data: Uint8Array of MP4 bytes; duration: seconds (frames.length / fps)
67
+ ```
68
+
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.
70
+
71
+ ### Schedule a Doc's Audio
72
+
73
+ `computeAudioTimeline(doc, coverPreRoll?)` turns a doc's narration segments and timed media clips into a flat list of absolute-timed `AudioTimelineClip`s. It's pure and Node-testable, and is the single source of truth both the browser MP4 export and the CLI mix path use to place audio (so the two never drift). Narration segments are laid sequentially; timed media clips are placed at their absolute positions; every start is shifted by `coverPreRoll` (default 0) to stay in sync with a silent cover pre-roll.
74
+
75
+ ```ts
76
+ import { computeAudioTimeline } from '@bendyline/squisq-video';
77
+
78
+ const clips = computeAudioTimeline(doc, 2); // [{ src, startSec, sourceInSec, durationSec }, …]
57
79
  ```
58
80
 
59
81
  ## Quality Presets
60
82
 
61
- | Preset | CRF | Speed | Use Case |
62
- | -------- | --- | --------- | ---------------------- |
63
- | `draft` | 28 | ultrafast | Quick previews |
64
- | `normal` | 23 | medium | General-purpose export |
65
- | `high` | 18 | slow | Final output |
83
+ Each `QualityPreset` also carries `bitsPerPixel` (for WebCodecs bitrate
84
+ targeting via `bitrateForQuality`) and `audioBitrate` (target AAC bits/sec):
85
+
86
+ | Preset | FFmpeg preset | CRF | bits/pixel | AAC bitrate | Use Case |
87
+ | -------- | ------------- | --- | ---------- | ----------- | ---------------------- |
88
+ | `draft` | ultrafast | 28 | 2 | 96 kbps | Quick previews |
89
+ | `normal` | medium | 23 | 4 | 128 kbps | General-purpose export |
90
+ | `high` | slow | 18 | 8 | 192 kbps | Final output |
66
91
 
67
92
  ## Orientation Dimensions
68
93
 
@@ -71,12 +96,16 @@ const mp4Bytes = await framesToMp4Wasm({
71
96
  | `landscape` | 1920 | 1080 |
72
97
  | `portrait` | 1080 | 1920 |
73
98
 
99
+ `resolveDimensions(options)` applies these defaults, honoring explicit `width`/`height` overrides.
100
+
101
+ See the full [API Reference](../../docs/API.md#bendylinesquisq-video) for all types.
102
+
74
103
  ## Related Packages
75
104
 
76
105
  | Package | Description |
77
106
  | -------------------------------------------------------------------------------------------- | ----------------------------------------------- |
78
107
  | [@bendyline/squisq](https://www.npmjs.com/package/@bendyline/squisq) | Headless core — schemas, templates, markdown |
79
- | [@bendyline/squisq-react](https://www.npmjs.com/package/@bendyline/squisq-react) | React components for rendering docs |
108
+ | [@bendyline/squisq-react](https://www.npmjs.com/package/@bendyline/squisq-react) | React components + standalone player bundle |
80
109
  | [@bendyline/squisq-video-react](https://www.npmjs.com/package/@bendyline/squisq-video-react) | React UI for in-browser video export |
81
110
  | [@bendyline/squisq-cli](https://www.npmjs.com/package/@bendyline/squisq-cli) | CLI for document conversion and video rendering |
82
111
 
package/dist/index.d.ts CHANGED
@@ -24,9 +24,29 @@ interface QualityPreset {
24
24
  preset: string;
25
25
  /** FFmpeg -crf value (lower = higher quality, 0-51 range) */
26
26
  crf: number;
27
+ /**
28
+ * Bits per pixel for WebCodecs bitrate targeting.
29
+ * Target bitrate = width * height * bitsPerPixel (see {@link bitrateForQuality}).
30
+ * These values reproduce the historical per-quality formula exactly:
31
+ * the old code used a `width * height * 4` baseline, halved for draft and
32
+ * doubled for high — i.e. 2 / 4 / 8 bits per pixel.
33
+ */
34
+ bitsPerPixel: number;
35
+ /** Target AAC audio bitrate in bits/sec for muxed audio tracks. */
36
+ audioBitrate: number;
27
37
  }
28
38
  /** Quality preset lookup — shared between wasm and native encoders. */
29
39
  declare const QUALITY_PRESETS: Record<VideoQuality, QualityPreset>;
40
+ /**
41
+ * Target H.264 bitrate (bits/sec) for a given quality at a given resolution.
42
+ *
43
+ * Computes `width * height * preset.bitsPerPixel`. With bitsPerPixel of
44
+ * 2 / 4 / 8 (draft / normal / high) this is numerically identical to the
45
+ * legacy formula (`width * height * 4` baseline, ×0.5 for draft, ×2 for high)
46
+ * that previously lived in both the main-thread and worker WebCodecs encoders.
47
+ * The single source of truth now lives here so every encode path agrees.
48
+ */
49
+ declare function bitrateForQuality(q: VideoQuality, width: number, height: number): number;
30
50
  /** Viewport dimensions for each orientation. */
31
51
  declare const ORIENTATION_DIMENSIONS: Record<VideoOrientation, {
32
52
  width: number;
@@ -66,6 +86,49 @@ declare function resolveDimensions(options: VideoExportOptions): {
66
86
  height: number;
67
87
  };
68
88
 
89
+ /**
90
+ * audioTimeline — Pure scheduling of a doc's audio onto the export timeline.
91
+ *
92
+ * Browser-pure and Node-testable: turns a {@link Doc} into a flat list of
93
+ * absolute-timed {@link AudioTimelineClip}s. This is the single source of
94
+ * truth the browser MP4 export uses to place audio, and it deliberately
95
+ * replicates the exact schedule math the CLI mix path uses so both agree:
96
+ *
97
+ * - Narration segments (`doc.audio.segments[]`) are laid **sequentially**,
98
+ * each starting where the previous one ended — mirroring the CLI, which
99
+ * concatenates the segment files in order.
100
+ * - Timed media clips (`block.media` + `doc.documentMedia`) are placed at
101
+ * their **absolute** doc-timeline positions via the shared
102
+ * `resolveMediaSchedule()` helper (the same one the CLI calls), honouring
103
+ * each clip's trim window (`sourceIn` / `absoluteEnd - absoluteStart`).
104
+ * - Every start time is shifted by `coverPreRoll` so a cover pre-roll padding
105
+ * (silent leading frames) keeps audio in sync.
106
+ */
107
+
108
+ /** One audio source placed on the absolute export timeline. */
109
+ interface AudioTimelineClip {
110
+ /** Source path (mp3/webm/mp4/…), relative to the doc's media dir. */
111
+ src: string;
112
+ /** Absolute second on the export timeline where this clip starts. */
113
+ startSec: number;
114
+ /** In-point within the source file to begin playback from. */
115
+ sourceInSec: number;
116
+ /** Played length in seconds (the trimmed window of the source). */
117
+ durationSec: number;
118
+ }
119
+ /**
120
+ * Flatten a doc's narration + timed-media audio into absolute-timed clips.
121
+ *
122
+ * Pure — depends only on `doc` (and reuses `resolveMediaSchedule` from core so
123
+ * the browser export and the CLI mix never drift). Returns `[]` for a doc with
124
+ * no audio at all.
125
+ *
126
+ * @param doc - The document to schedule audio for.
127
+ * @param coverPreRoll - Leading silent padding (seconds) added ahead of every
128
+ * clip, matching the cover-slide pre-roll frames. Default 0.
129
+ */
130
+ declare function computeAudioTimeline(doc: Doc, coverPreRoll?: number): AudioTimelineClip[];
131
+
69
132
  /**
70
133
  * Render HTML Generation for Video Frame Capture
71
134
  *
@@ -111,6 +174,29 @@ interface RenderHtmlOptions {
111
174
  */
112
175
  declare function generateRenderHtml(doc: Doc, options: RenderHtmlOptions): string;
113
176
 
177
+ /**
178
+ * FFmpeg argument builders — the single source of truth for translating a
179
+ * {@link VideoQuality} into ffmpeg CLI flags. Shared verbatim by every
180
+ * ffmpeg-based encode path: the wasm encoder ({@link ./wasmEncoder}), the
181
+ * video-react fallback worker, and the CLI native encoder. Deriving these
182
+ * from {@link QUALITY_PRESETS} keeps the browser and CLI byte-for-byte aligned.
183
+ *
184
+ * Pure, dependency-free, and unit-testable in isolation (the actual ffmpeg
185
+ * invocations live behind wasm/child-process boundaries that are awkward to
186
+ * exercise directly).
187
+ */
188
+
189
+ /**
190
+ * H.264 speed/quality flags (`-preset`, `-crf`) for a quality level.
191
+ * @example ffmpegVideoQualityArgs('high') // ['-preset', 'slow', '-crf', '18']
192
+ */
193
+ declare function ffmpegVideoQualityArgs(quality: VideoQuality): string[];
194
+ /**
195
+ * AAC audio-bitrate flag value in ffmpeg's `k` shorthand for a quality level.
196
+ * @example audioBitrateArg('high') // '192k'
197
+ */
198
+ declare function audioBitrateArg(quality: VideoQuality): string;
199
+
114
200
  /**
115
201
  * WASM Video Encoder
116
202
  *
@@ -131,4 +217,4 @@ declare function generateRenderHtml(doc: Doc, options: RenderHtmlOptions): strin
131
217
  */
132
218
  declare function framesToMp4Wasm(frames: Uint8Array[], audio: Uint8Array | null, options?: VideoExportOptions): Promise<EncoderResult>;
133
219
 
134
- export { type EncoderResult, ORIENTATION_DIMENSIONS, QUALITY_PRESETS, type QualityPreset, type RenderHtmlOptions, type VideoExportOptions, type VideoOrientation, type VideoQuality, framesToMp4Wasm, generateRenderHtml, resolveDimensions };
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 };
package/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  // src/types.ts
2
2
  var QUALITY_PRESETS = {
3
- draft: { preset: "ultrafast", crf: 28 },
4
- normal: { preset: "medium", crf: 23 },
5
- high: { preset: "slow", crf: 18 }
3
+ draft: { preset: "ultrafast", crf: 28, bitsPerPixel: 2, audioBitrate: 96e3 },
4
+ normal: { preset: "medium", crf: 23, bitsPerPixel: 4, audioBitrate: 128e3 },
5
+ high: { preset: "slow", crf: 18, bitsPerPixel: 8, audioBitrate: 192e3 }
6
6
  };
7
+ function bitrateForQuality(q, width, height) {
8
+ const preset = QUALITY_PRESETS[q] ?? QUALITY_PRESETS.normal;
9
+ return Math.round(width * height * preset.bitsPerPixel);
10
+ }
7
11
  var ORIENTATION_DIMENSIONS = {
8
12
  landscape: { width: 1920, height: 1080 },
9
13
  portrait: { width: 1080, height: 1920 }
@@ -17,6 +21,33 @@ function resolveDimensions(options) {
17
21
  };
18
22
  }
19
23
 
24
+ // src/audioTimeline.ts
25
+ import { resolveMediaSchedule } from "@bendyline/squisq/schemas";
26
+ function computeAudioTimeline(doc, coverPreRoll = 0) {
27
+ const preRoll = coverPreRoll > 0 ? coverPreRoll : 0;
28
+ const clips = [];
29
+ let cursor = 0;
30
+ for (const seg of doc.audio?.segments ?? []) {
31
+ const durationSec = Math.max(0, seg.duration);
32
+ if (durationSec > 0 && seg.src) {
33
+ clips.push({ src: seg.src, startSec: cursor + preRoll, sourceInSec: 0, durationSec });
34
+ }
35
+ cursor += durationSec;
36
+ }
37
+ for (const clip of resolveMediaSchedule(doc)) {
38
+ if (clip.kind !== "audio") continue;
39
+ const durationSec = Math.max(0, clip.absoluteEnd - clip.absoluteStart);
40
+ if (durationSec <= 0 || !clip.src) continue;
41
+ clips.push({
42
+ src: clip.src,
43
+ startSec: clip.absoluteStart + preRoll,
44
+ sourceInSec: clip.sourceIn,
45
+ durationSec
46
+ });
47
+ }
48
+ return clips;
49
+ }
50
+
20
51
  // src/renderHtml.ts
21
52
  var MIME_MAP = {
22
53
  jpg: "image/jpeg",
@@ -105,6 +136,16 @@ html,body{margin:0;padding:0;width:${width}px;height:${height}px;overflow:hidden
105
136
  </html>`;
106
137
  }
107
138
 
139
+ // src/ffmpegArgs.ts
140
+ function ffmpegVideoQualityArgs(quality) {
141
+ const preset = QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal;
142
+ return ["-preset", preset.preset, "-crf", String(preset.crf)];
143
+ }
144
+ function audioBitrateArg(quality) {
145
+ const preset = QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal;
146
+ return `${preset.audioBitrate / 1e3}k`;
147
+ }
148
+
108
149
  // src/wasmEncoder.ts
109
150
  import { FFmpeg } from "@ffmpeg/ffmpeg";
110
151
  import { fetchFile } from "@ffmpeg/util";
@@ -112,7 +153,6 @@ async function framesToMp4Wasm(frames, audio, options = {}) {
112
153
  const fps = options.fps ?? 30;
113
154
  const quality = options.quality ?? "normal";
114
155
  const { width, height } = resolveDimensions(options);
115
- const preset = QUALITY_PRESETS[quality];
116
156
  const onProgress = options.onProgress;
117
157
  if (frames.length === 0) {
118
158
  throw new Error("No frames provided for encoding");
@@ -147,17 +187,14 @@ async function framesToMp4Wasm(frames, audio, options = {}) {
147
187
  args.push(
148
188
  "-c:v",
149
189
  "libx264",
150
- "-preset",
151
- preset.preset,
152
- "-crf",
153
- String(preset.crf),
190
+ ...ffmpegVideoQualityArgs(quality),
154
191
  "-pix_fmt",
155
192
  "yuv420p",
156
193
  "-vf",
157
194
  `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`
158
195
  );
159
196
  if (audio) {
160
- args.push("-c:a", "aac", "-b:a", "128k", "-shortest");
197
+ args.push("-c:a", "aac", "-b:a", audioBitrateArg(quality), "-shortest");
161
198
  }
162
199
  args.push("output.mp4");
163
200
  await ffmpeg.exec(args);
@@ -179,7 +216,11 @@ async function framesToMp4Wasm(frames, audio, options = {}) {
179
216
  export {
180
217
  ORIENTATION_DIMENSIONS,
181
218
  QUALITY_PRESETS,
219
+ audioBitrateArg,
220
+ bitrateForQuality,
221
+ computeAudioTimeline,
182
222
  fetchFile,
223
+ ffmpegVideoQualityArgs,
183
224
  framesToMp4Wasm,
184
225
  generateRenderHtml,
185
226
  resolveDimensions
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts","../src/renderHtml.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\n/** Quality preset lookup — shared between wasm and native encoders. */\nexport const QUALITY_PRESETS: Record<VideoQuality, QualityPreset> = {\n draft: { preset: 'ultrafast', crf: 28 },\n normal: { preset: 'medium', crf: 23 },\n high: { preset: 'slow', crf: 18 },\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 * 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 * 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 { QUALITY_PRESETS, resolveDimensions } from './types.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 preset = QUALITY_PRESETS[quality];\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 '-preset',\n preset.preset,\n '-crf',\n String(preset.crf),\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', '128k', '-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/** 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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-video",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Video encoding and render HTML generation for Squisq documents — browser-pure, works in Node and browser",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -40,7 +40,7 @@
40
40
  "typecheck": "tsc --noEmit"
41
41
  },
42
42
  "dependencies": {
43
- "@bendyline/squisq": "1.5.0",
43
+ "@bendyline/squisq": "1.5.2",
44
44
  "@ffmpeg/ffmpeg": "0.12.15",
45
45
  "@ffmpeg/util": "0.12.2"
46
46
  },