@bendyline/squisq-video 1.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.
@@ -0,0 +1,134 @@
1
+ import { Doc } from '@bendyline/squisq/schemas';
2
+ export { fetchFile } from '@ffmpeg/util';
3
+
4
+ /**
5
+ * Video Export Types
6
+ *
7
+ * Shared type definitions for video encoding and render HTML generation.
8
+ * Used by both the browser-based encoder and the CLI native encoder.
9
+ */
10
+ /**
11
+ * Video quality preset.
12
+ * Controls the H.264 encoding speed/quality trade-off and constant rate factor.
13
+ *
14
+ * - draft: ultrafast preset, CRF 28 — fast encode, lower quality (~1-2 Mbps)
15
+ * - normal: medium preset, CRF 23 — balanced (~3-5 Mbps)
16
+ * - high: slow preset, CRF 18 — best quality, slowest (~8-12 Mbps)
17
+ */
18
+ type VideoQuality = 'draft' | 'normal' | 'high';
19
+ /** Viewport orientation for video output. */
20
+ type VideoOrientation = 'landscape' | 'portrait';
21
+ /** Encoding preset parameters mapped from VideoQuality. */
22
+ interface QualityPreset {
23
+ /** FFmpeg -preset value (ultrafast, medium, slow) */
24
+ preset: string;
25
+ /** FFmpeg -crf value (lower = higher quality, 0-51 range) */
26
+ crf: number;
27
+ }
28
+ /** Quality preset lookup — shared between wasm and native encoders. */
29
+ declare const QUALITY_PRESETS: Record<VideoQuality, QualityPreset>;
30
+ /** Viewport dimensions for each orientation. */
31
+ declare const ORIENTATION_DIMENSIONS: Record<VideoOrientation, {
32
+ width: number;
33
+ height: number;
34
+ }>;
35
+ /** Options for video export encoding. */
36
+ interface VideoExportOptions {
37
+ /** Frames per second (default: 30) */
38
+ fps?: number;
39
+ /** Video width in pixels (default: based on orientation) */
40
+ width?: number;
41
+ /** Video height in pixels (default: based on orientation) */
42
+ height?: number;
43
+ /** Encoding quality preset (default: 'normal') */
44
+ quality?: VideoQuality;
45
+ /** Viewport orientation (default: 'landscape') */
46
+ orientation?: VideoOrientation;
47
+ /**
48
+ * Progress callback. Called during encoding with completion percentage and phase description.
49
+ * @param percent - 0-100 completion percentage
50
+ * @param phase - Human-readable description of current phase (e.g., 'encoding', 'muxing')
51
+ */
52
+ onProgress?: (percent: number, phase: string) => void;
53
+ }
54
+ /** Result from the wasm encoder. */
55
+ interface EncoderResult {
56
+ /** MP4 file bytes */
57
+ data: Uint8Array;
58
+ /** Video duration in seconds */
59
+ duration: number;
60
+ }
61
+ /**
62
+ * Resolve dimensions from options, applying orientation defaults.
63
+ */
64
+ declare function resolveDimensions(options: VideoExportOptions): {
65
+ width: number;
66
+ height: number;
67
+ };
68
+
69
+ /**
70
+ * Render HTML Generation for Video Frame Capture
71
+ *
72
+ * Generates a self-contained HTML document that loads the SquisqPlayer standalone
73
+ * bundle in renderMode, embedding all images and audio as base64 data URIs.
74
+ *
75
+ * The generated page exposes `window.seekTo(time)`, `window.getDuration()`, etc.
76
+ * via the SquisqRenderAPI, enabling Playwright (or any headless browser) to step
77
+ * through frames and capture screenshots.
78
+ *
79
+ * Browser-pure: uses only btoa() and Uint8Array — no Node.js APIs.
80
+ */
81
+
82
+ interface RenderHtmlOptions {
83
+ /** The IIFE player bundle source code (from PLAYER_BUNDLE) */
84
+ playerScript: string;
85
+ /**
86
+ * Map of relative image paths (as they appear in the Doc) to binary data.
87
+ * Converted to base64 data URIs and embedded in the HTML.
88
+ */
89
+ images?: Map<string, ArrayBuffer>;
90
+ /**
91
+ * Map of audio segment names/paths to binary audio data.
92
+ * Converted to base64 data URIs and embedded in the HTML.
93
+ */
94
+ audio?: Map<string, ArrayBuffer>;
95
+ /** Viewport width in CSS pixels (default: 1920) */
96
+ width?: number;
97
+ /** Viewport height in CSS pixels (default: 1080) */
98
+ height?: number;
99
+ /** Caption style for the rendered video. Omit for no captions. */
100
+ captionStyle?: 'standard' | 'social';
101
+ }
102
+ /**
103
+ * Generate a self-contained HTML document for headless video frame capture.
104
+ *
105
+ * The page mounts the SquisqPlayer in renderMode, which exposes the
106
+ * SquisqRenderAPI on `window` (seekTo, getDuration, getCaptions, etc.).
107
+ *
108
+ * @param doc - The Doc to render
109
+ * @param options - Render HTML options including player script and media
110
+ * @returns Complete HTML string ready to be loaded in a headless browser
111
+ */
112
+ declare function generateRenderHtml(doc: Doc, options: RenderHtmlOptions): string;
113
+
114
+ /**
115
+ * WASM Video Encoder
116
+ *
117
+ * Encodes PNG frame screenshots into an MP4 video using ffmpeg.wasm.
118
+ * Browser-pure — no Node.js APIs. Works in any environment with SharedArrayBuffer
119
+ * support (browsers with COOP/COEP headers, or Node 18+).
120
+ *
121
+ * Uses @ffmpeg/ffmpeg for H.264 encoding and optional AAC audio muxing.
122
+ */
123
+
124
+ /**
125
+ * Encode an array of PNG frame screenshots into an MP4 video.
126
+ *
127
+ * @param frames - Array of PNG image bytes (one per frame, in order)
128
+ * @param audio - Optional WAV/MP3/AAC audio bytes to mux into the video
129
+ * @param options - Encoding options (fps, quality, dimensions, progress)
130
+ * @returns Encoded MP4 data and duration metadata
131
+ */
132
+ declare function framesToMp4Wasm(frames: Uint8Array[], audio: Uint8Array | null, options?: VideoExportOptions): Promise<EncoderResult>;
133
+
134
+ export { type EncoderResult, ORIENTATION_DIMENSIONS, QUALITY_PRESETS, type QualityPreset, type RenderHtmlOptions, type VideoExportOptions, type VideoOrientation, type VideoQuality, framesToMp4Wasm, generateRenderHtml, resolveDimensions };
package/dist/index.js ADDED
@@ -0,0 +1,187 @@
1
+ // src/types.ts
2
+ var QUALITY_PRESETS = {
3
+ draft: { preset: "ultrafast", crf: 28 },
4
+ normal: { preset: "medium", crf: 23 },
5
+ high: { preset: "slow", crf: 18 }
6
+ };
7
+ var ORIENTATION_DIMENSIONS = {
8
+ landscape: { width: 1920, height: 1080 },
9
+ portrait: { width: 1080, height: 1920 }
10
+ };
11
+ function resolveDimensions(options) {
12
+ const orientation = options.orientation ?? "landscape";
13
+ const defaults = ORIENTATION_DIMENSIONS[orientation];
14
+ return {
15
+ width: options.width ?? defaults.width,
16
+ height: options.height ?? defaults.height
17
+ };
18
+ }
19
+
20
+ // src/renderHtml.ts
21
+ var MIME_MAP = {
22
+ jpg: "image/jpeg",
23
+ jpeg: "image/jpeg",
24
+ png: "image/png",
25
+ gif: "image/gif",
26
+ webp: "image/webp",
27
+ svg: "image/svg+xml",
28
+ bmp: "image/bmp",
29
+ avif: "image/avif",
30
+ mp3: "audio/mpeg",
31
+ wav: "audio/wav",
32
+ ogg: "audio/ogg",
33
+ mp4: "video/mp4",
34
+ webm: "video/webm"
35
+ };
36
+ function inferMimeType(filename) {
37
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
38
+ return MIME_MAP[ext] ?? "application/octet-stream";
39
+ }
40
+ function arrayBufferToDataUrl(buffer, mimeType) {
41
+ const bytes = new Uint8Array(buffer);
42
+ let binary = "";
43
+ for (let i = 0; i < bytes.length; i++) {
44
+ binary += String.fromCharCode(bytes[i]);
45
+ }
46
+ return `data:${mimeType};base64,${btoa(binary)}`;
47
+ }
48
+ function escapeForScript(str) {
49
+ return str.replace(/<\/(script)/gi, "<\\/$1");
50
+ }
51
+ function escapeHtml(str) {
52
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
53
+ }
54
+ function generateRenderHtml(doc, options) {
55
+ const { playerScript, images, audio, width = 1920, height = 1080, captionStyle } = options;
56
+ const imageMap = {};
57
+ if (images) {
58
+ for (const [path, buffer] of images.entries()) {
59
+ imageMap[path] = arrayBufferToDataUrl(buffer, inferMimeType(path));
60
+ }
61
+ }
62
+ const audioMap = {};
63
+ let hasAudio = false;
64
+ if (audio) {
65
+ for (const [name, buffer] of audio.entries()) {
66
+ audioMap[name] = arrayBufferToDataUrl(buffer, inferMimeType(name));
67
+ hasAudio = true;
68
+ }
69
+ }
70
+ const docJson = escapeForScript(JSON.stringify(doc));
71
+ const imageMapJson = escapeForScript(JSON.stringify(imageMap));
72
+ const audioMapJson = hasAudio ? escapeForScript(JSON.stringify(audioMap)) : "null";
73
+ return `<!DOCTYPE html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="UTF-8">
77
+ <meta name="viewport" content="width=${width}, height=${height}">
78
+ <title>${escapeHtml("Squisq Video Render")}</title>
79
+ <style>
80
+ *,*::before,*::after{box-sizing:border-box}
81
+ html,body{margin:0;padding:0;width:${width}px;height:${height}px;overflow:hidden;background:#000}
82
+ #squisq-root{width:${width}px;height:${height}px;display:flex;align-items:center;justify-content:center}
83
+ </style>
84
+ </head>
85
+ <body>
86
+ <div id="squisq-root"></div>
87
+ <script>${escapeForScript(playerScript)}</script>
88
+ <script>
89
+ (function(){
90
+ var doc = JSON.parse(${JSON.stringify(docJson)});
91
+ var images = JSON.parse(${JSON.stringify(imageMapJson)});
92
+ var audio = ${audioMapJson === "null" ? "null" : "JSON.parse(" + JSON.stringify(audioMapJson) + ")"};
93
+ SquisqPlayer.mount(document.getElementById("squisq-root"), doc, {
94
+ mode: "slideshow",
95
+ images: images,
96
+ audio: audio,
97
+ autoPlay: false,
98
+ basePath: ".",
99
+ renderMode: true${captionStyle ? `,
100
+ captionStyle: ${JSON.stringify(captionStyle)}` : ""}
101
+ });
102
+ })();
103
+ </script>
104
+ </body>
105
+ </html>`;
106
+ }
107
+
108
+ // src/wasmEncoder.ts
109
+ import { FFmpeg } from "@ffmpeg/ffmpeg";
110
+ import { fetchFile } from "@ffmpeg/util";
111
+ async function framesToMp4Wasm(frames, audio, options = {}) {
112
+ const fps = options.fps ?? 30;
113
+ const quality = options.quality ?? "normal";
114
+ const { width, height } = resolveDimensions(options);
115
+ const preset = QUALITY_PRESETS[quality];
116
+ const onProgress = options.onProgress;
117
+ if (frames.length === 0) {
118
+ throw new Error("No frames provided for encoding");
119
+ }
120
+ const duration = frames.length / fps;
121
+ const ffmpeg = new FFmpeg();
122
+ ffmpeg.on("progress", ({ progress }) => {
123
+ if (onProgress) {
124
+ const percent = Math.round(progress * 100);
125
+ onProgress(Math.min(percent, 99), "encoding");
126
+ }
127
+ });
128
+ await ffmpeg.load();
129
+ onProgress?.(0, "writing frames");
130
+ const padLen = String(frames.length).length;
131
+ for (let i = 0; i < frames.length; i++) {
132
+ const name = `frame-${String(i + 1).padStart(padLen, "0")}.png`;
133
+ await ffmpeg.writeFile(name, frames[i]);
134
+ if (onProgress && i % 10 === 0) {
135
+ onProgress(Math.round(i / frames.length * 40), "writing frames");
136
+ }
137
+ }
138
+ if (audio) {
139
+ await ffmpeg.writeFile("audio-input", audio);
140
+ }
141
+ onProgress?.(40, "encoding");
142
+ const padPattern = `frame-%0${padLen}d.png`;
143
+ const args = ["-y", "-framerate", String(fps), "-i", padPattern];
144
+ if (audio) {
145
+ args.push("-i", "audio-input");
146
+ }
147
+ args.push(
148
+ "-c:v",
149
+ "libx264",
150
+ "-preset",
151
+ preset.preset,
152
+ "-crf",
153
+ String(preset.crf),
154
+ "-pix_fmt",
155
+ "yuv420p",
156
+ "-vf",
157
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`
158
+ );
159
+ if (audio) {
160
+ args.push("-c:a", "aac", "-b:a", "128k", "-shortest");
161
+ }
162
+ args.push("output.mp4");
163
+ await ffmpeg.exec(args);
164
+ onProgress?.(95, "reading output");
165
+ const data = await ffmpeg.readFile("output.mp4");
166
+ for (let i = 0; i < frames.length; i++) {
167
+ const name = `frame-${String(i + 1).padStart(padLen, "0")}.png`;
168
+ await ffmpeg.deleteFile(name);
169
+ }
170
+ if (audio) {
171
+ await ffmpeg.deleteFile("audio-input");
172
+ }
173
+ await ffmpeg.deleteFile("output.mp4");
174
+ ffmpeg.terminate();
175
+ onProgress?.(100, "done");
176
+ const outputData = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
177
+ return { data: outputData, duration };
178
+ }
179
+ export {
180
+ ORIENTATION_DIMENSIONS,
181
+ QUALITY_PRESETS,
182
+ fetchFile,
183
+ framesToMp4Wasm,
184
+ generateRenderHtml,
185
+ resolveDimensions
186
+ };
187
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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":[]}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@bendyline/squisq-video",
3
+ "version": "1.0.0",
4
+ "description": "Video encoding and render HTML generation for Squisq documents — browser-pure, works in Node and browser",
5
+ "license": "MIT",
6
+ "author": "Bendyline",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/bendyline/squisq.git",
10
+ "directory": "packages/video"
11
+ },
12
+ "homepage": "https://github.com/bendyline/squisq",
13
+ "keywords": [
14
+ "squisq",
15
+ "video",
16
+ "mp4",
17
+ "ffmpeg",
18
+ "wasm",
19
+ "export"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "typecheck": "tsc --noEmit"
40
+ },
41
+ "dependencies": {
42
+ "@bendyline/squisq": "1.1.0",
43
+ "@bendyline/squisq-react": "1.0.2",
44
+ "@ffmpeg/ffmpeg": "^0.12.15",
45
+ "@ffmpeg/util": "^0.12.2"
46
+ },
47
+ "devDependencies": {
48
+ "tsup": "^8.0.0",
49
+ "typescript": "^5.3.0"
50
+ }
51
+ }