@bendyline/squisq-cli 1.2.2 → 2.0.1

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 @@
1
+ {"version":3,"sources":["../src/util/nativeEncoder.ts","../src/util/runFfmpeg.ts"],"sourcesContent":["/**\n * Native FFmpeg Encoder\n *\n * Encodes PNG frames to MP4 or animated GIF using a locally installed ffmpeg\n * binary. Writes frames to a temporary directory, invokes ffmpeg as a child\n * process, and reads the resulting media file.\n *\n * This is the Node fast path used after the CLI detects native FFmpeg. Browser\n * callers use the WebCodecs/ffmpeg.wasm paths in the video packages instead.\n */\n\nimport { writeFile, readFile, mkdir, rm } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { randomBytes } from 'node:crypto';\n\nimport type { GifDither, VideoExportOptions, VideoOrientation } from '@bendyline/squisq-video';\nimport {\n resolveDimensions,\n ffmpegVideoQualityArgs,\n audioBitrateArg,\n ffmpegAudioMuxArgs,\n ffmpegGifOutputArgs,\n} from '@bendyline/squisq-video';\nimport { runFfmpeg } from './runFfmpeg.js';\n\n/** Node-native video options with cooperative and child-process cancellation. */\nexport interface NativeVideoExportOptions extends VideoExportOptions {\n signal?: AbortSignal;\n}\n\n/** Options for native animated-GIF encoding. */\nexport interface GifExportOptions {\n /** Cancel staging or encoding and terminate the native FFmpeg process. */\n signal?: AbortSignal;\n /** Frames per second (default: 10; GIF timing is centisecond-based). */\n fps?: number;\n /** Output width in pixels (default: 960 landscape / 540 portrait). */\n width?: number;\n /** Output height in pixels (default: 540 landscape / 960 portrait). */\n height?: number;\n /** Viewport orientation used for default dimensions. */\n orientation?: VideoOrientation;\n /** Number of repeats; 0 loops forever and -1 disables looping. */\n loop?: number;\n /** Palette size, between 2 and 256 (default: 256). */\n maxColors?: number;\n /** Palette dithering algorithm (default: sierra2_4a). */\n dither?: GifDither;\n /** Bayer strength when dither is bayer (0-5, default: 3). */\n bayerScale?: number;\n /** Encoding progress callback. */\n onProgress?: (percent: number, phase: string) => void;\n}\n\n/** Compression-friendly defaults for animated GIF output. */\nexport const GIF_EXPORT_DEFAULTS = {\n fps: 10,\n width: 960,\n height: 540,\n loop: 0,\n maxColors: 256,\n dither: 'sierra2_4a' as GifDither,\n} as const;\n\nfunction resolveGifDimensions(options: GifExportOptions): { width: number; height: number } {\n const portrait = options.orientation === 'portrait';\n const defaults = portrait\n ? { width: GIF_EXPORT_DEFAULTS.height, height: GIF_EXPORT_DEFAULTS.width }\n : { width: GIF_EXPORT_DEFAULTS.width, height: GIF_EXPORT_DEFAULTS.height };\n const width = options.width ?? defaults.width;\n const height = options.height ?? defaults.height;\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 return { width, height };\n}\n\nfunction resolveGifFps(options: GifExportOptions): number {\n const fps = options.fps ?? GIF_EXPORT_DEFAULTS.fps;\n // GIF delays are stored in centiseconds, so rates above 100 cannot be\n // represented without timestamp rounding.\n if (!Number.isFinite(fps) || fps <= 0 || fps > 100) {\n throw new RangeError('GIF FPS must be a finite number between 1 and 100.');\n }\n return fps;\n}\n\n/**\n * Encode frame PNGs to MP4 using native ffmpeg.\n *\n * @param ffmpegPath - Absolute path to the ffmpeg binary\n * @param frames - Array of PNG image bytes (one per frame)\n * @param audio - Optional audio file bytes to mux\n * @param outputPath - Where to write the final MP4\n * @param options - Encoding options (fps, quality, dimensions, progress)\n */\nexport async function framesToMp4Native(\n ffmpegPath: string,\n frames: Uint8Array[],\n audio: Uint8Array | null,\n outputPath: string,\n options: NativeVideoExportOptions = {},\n): Promise<void> {\n options.signal?.throwIfAborted();\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 // Create a temp working directory\n const tmpId = randomBytes(8).toString('hex');\n const workDir = join(tmpdir(), `squisq-video-${tmpId}`);\n await mkdir(workDir, { recursive: true });\n\n try {\n options.signal?.throwIfAborted();\n onProgress?.(0, 'writing frames');\n\n // Write frame PNGs to temp directory\n const padLen = String(frames.length).length;\n for (let i = 0; i < frames.length; i++) {\n options.signal?.throwIfAborted();\n const name = `frame-${String(i + 1).padStart(padLen, '0')}.png`;\n await writeFile(join(workDir, name), frames[i]);\n options.signal?.throwIfAborted();\n\n if (onProgress && i % 10 === 0) {\n onProgress(Math.round((i / frames.length) * 30), 'writing frames');\n }\n }\n\n // Write audio if provided\n let audioPath: string | null = null;\n if (audio) {\n options.signal?.throwIfAborted();\n audioPath = join(workDir, 'audio-input');\n await writeFile(audioPath, audio);\n options.signal?.throwIfAborted();\n }\n\n onProgress?.(30, 'encoding');\n\n // Build ffmpeg arguments\n const padPattern = join(workDir, `frame-%0${padLen}d.png`);\n const args = ['-y', '-framerate', String(fps), '-i', padPattern];\n\n if (audioPath) {\n args.push('-i', audioPath);\n }\n\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 if (audioPath) {\n args.push(...ffmpegAudioMuxArgs(audioBitrateArg(quality)));\n }\n\n args.push(outputPath);\n\n // Run ffmpeg. execFile buffers stdout/stderr internally for the callback,\n // so the child's pipes are already drained — no manual stderr listener needed.\n await runFfmpeg(ffmpegPath, args, {\n timeoutMs: 600_000,\n failureMessage: 'ffmpeg failed',\n signal: options.signal,\n });\n\n onProgress?.(100, 'done');\n options.signal?.throwIfAborted();\n } finally {\n // Clean up temp directory\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\n/**\n * Encode frames using native ffmpeg and return the MP4 bytes (instead of writing to disk).\n * Useful when the caller needs the bytes in memory.\n */\nexport async function framesToMp4NativeBytes(\n ffmpegPath: string,\n frames: Uint8Array[],\n audio: Uint8Array | null,\n options: NativeVideoExportOptions = {},\n): Promise<Uint8Array> {\n options.signal?.throwIfAborted();\n const tmpId = randomBytes(8).toString('hex');\n const tmpOutput = join(tmpdir(), `squisq-video-out-${tmpId}.mp4`);\n\n try {\n await framesToMp4Native(ffmpegPath, frames, audio, tmpOutput, options);\n options.signal?.throwIfAborted();\n const data = await readFile(tmpOutput);\n options.signal?.throwIfAborted();\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(tmpOutput, { force: true });\n }\n}\n\n/**\n * Encode PNG frames to an animated GIF using native FFmpeg.\n *\n * The shared palette graph uses changed-frame statistics and rectangle-scoped\n * palette application so static slide regions remain stable and compress well.\n * GIF has no audio track; callers should surface that limitation before calling.\n */\nexport async function framesToGifNative(\n ffmpegPath: string,\n frames: Uint8Array[],\n outputPath: string,\n options: GifExportOptions = {},\n): Promise<void> {\n options.signal?.throwIfAborted();\n const fps = resolveGifFps(options);\n const { width, height } = resolveGifDimensions(options);\n const onProgress = options.onProgress;\n\n if (frames.length === 0) {\n throw new Error('No frames provided for encoding');\n }\n const gifOutputArgs = ffmpegGifOutputArgs({\n width,\n height,\n loop: options.loop ?? GIF_EXPORT_DEFAULTS.loop,\n maxColors: options.maxColors ?? GIF_EXPORT_DEFAULTS.maxColors,\n dither: options.dither ?? GIF_EXPORT_DEFAULTS.dither,\n bayerScale: options.bayerScale,\n });\n\n const tmpId = randomBytes(8).toString('hex');\n const workDir = join(tmpdir(), `squisq-gif-${tmpId}`);\n await mkdir(workDir, { recursive: true });\n\n try {\n options.signal?.throwIfAborted();\n onProgress?.(0, 'writing frames');\n const padLen = String(frames.length).length;\n for (let i = 0; i < frames.length; i++) {\n options.signal?.throwIfAborted();\n const name = `frame-${String(i + 1).padStart(padLen, '0')}.png`;\n await writeFile(join(workDir, name), frames[i]);\n options.signal?.throwIfAborted();\n if (onProgress && i % 10 === 0) {\n onProgress(Math.round((i / frames.length) * 30), 'writing frames');\n }\n }\n\n onProgress?.(30, 'encoding');\n const padPattern = join(workDir, `frame-%0${padLen}d.png`);\n const args = ['-y', '-framerate', String(fps), '-i', padPattern, ...gifOutputArgs, outputPath];\n\n await runFfmpeg(ffmpegPath, args, {\n timeoutMs: 600_000,\n failureMessage: 'ffmpeg GIF encoding failed',\n signal: options.signal,\n });\n onProgress?.(100, 'done');\n options.signal?.throwIfAborted();\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\n/** Encode frames with native FFmpeg and return animated-GIF bytes. */\nexport async function framesToGifNativeBytes(\n ffmpegPath: string,\n frames: Uint8Array[],\n options: GifExportOptions = {},\n): Promise<Uint8Array> {\n options.signal?.throwIfAborted();\n const tmpId = randomBytes(8).toString('hex');\n const tmpOutput = join(tmpdir(), `squisq-gif-out-${tmpId}.gif`);\n try {\n await framesToGifNative(ffmpegPath, frames, tmpOutput, options);\n options.signal?.throwIfAborted();\n const data = await readFile(tmpOutput);\n options.signal?.throwIfAborted();\n return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);\n } finally {\n await rm(tmpOutput, { force: true });\n }\n}\n","import { execFile } from 'node:child_process';\n\nexport interface RunFfmpegOptions {\n timeoutMs: number;\n failureMessage: string;\n signal?: AbortSignal;\n}\n\n/** Run one bounded FFmpeg-style child process while preserving caller cancellation. */\nexport async function runFfmpeg(\n command: string,\n args: readonly string[],\n options: RunFfmpegOptions,\n): Promise<void> {\n options.signal?.throwIfAborted();\n await new Promise<void>((resolve, reject) => {\n execFile(command, [...args], { timeout: options.timeoutMs, signal: options.signal }, (err) => {\n if (options.signal?.aborted) {\n reject(options.signal.reason);\n } else if (err) {\n reject(new Error(`${options.failureMessage}: ${err.message}`));\n } else {\n resolve();\n }\n });\n });\n options.signal?.throwIfAborted();\n}\n"],"mappings":"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","names":[]}