wasm-html-to-image 1.0.2 → 1.0.3

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
@@ -273,6 +273,12 @@ npx wasm-html-to-image https://example.com/news -o news.png --base-url https://e
273
273
 
274
274
  # Convert and resize an image
275
275
  npx wasm-html-to-image photo.jpg -o photo.avif -w 800 -f avif -q 80
276
+
277
+ # Sample an opacity / transform / color @keyframes animation into an animated WebP
278
+ npx wasm-html-to-image loader.html -o loader.webp -f webp --animation 2000 --animation-fps 20
279
+
280
+ # Play the animated WebP once instead of looping forever
281
+ npx wasm-html-to-image loader.html -o loader.webp -f webp --animation 2000 --animation-loop 1
276
282
  ```
277
283
 
278
284
  ---
@@ -5,16 +5,29 @@ import { createRenderActions } from "./worker-actions.js";
5
5
  // @ts-expect-error — dist/html-to-image.js is generated at build time
6
6
  import createHtmlToImageModule from "../dist/html-to-image.js";
7
7
  const { initWorker } = await loadWorkerLib();
8
- let mod;
9
- /** Lazily load (and reuse) the module inside this worker. */
8
+ let modPromise;
9
+ /**
10
+ * Lazily load (and reuse) the module inside this worker.
11
+ *
12
+ * The pending promise is shared: parallel jobs arriving while the module
13
+ * is still loading wait for that single load instead of starting a second
14
+ * instantiation (a 12 MB binary plus its linear memory, each). A failed
15
+ * load clears the promise so the next job retries.
16
+ */
10
17
  const getModule = async () => {
11
- if (!mod) {
12
- mod = await loadHtmlToImageModule({
13
- factory: createHtmlToImageModule,
14
- moduleArg: { wasmBinary: await loadCompressedWasmBinary() },
18
+ if (!modPromise) {
19
+ modPromise = (async () => {
20
+ const wasmBinary = await loadCompressedWasmBinary();
21
+ return loadHtmlToImageModule({
22
+ factory: createHtmlToImageModule,
23
+ moduleArg: { wasmBinary },
24
+ });
25
+ })().catch((e) => {
26
+ modPromise = undefined;
27
+ throw e;
15
28
  });
16
29
  }
17
- return mod;
30
+ return modPromise;
18
31
  };
19
32
  /**
20
33
  * Browser worker-side implementation (pre-bundled `dist/web-workers.js`).
@@ -8,7 +8,39 @@ export interface CliFlags {
8
8
  quality: number;
9
9
  /** Explicit `--base-url`; absent means "let the source decide". */
10
10
  baseUrl?: string;
11
+ /** `--animation <duration-ms>`; absent means "no keyframe animation". */
12
+ animation?: number;
13
+ /** `--animation-fps <n>`; defaults to 15 when `--animation` is set. */
14
+ animationFps?: number;
15
+ /**
16
+ * `--animation-loop <n>`; how many times the animated WebP repeats.
17
+ * Absent means infinite (libwebp loop_count 0 = the historical default).
18
+ */
19
+ animationLoop?: number;
11
20
  }
21
+ /** Default frame rate for `--animation` when `--animation-fps` is omitted. */
22
+ export declare const DEFAULT_ANIMATION_FPS = 15;
23
+ /**
24
+ * Animation flags resolved against the output format.
25
+ *
26
+ * Animated WebP is the only animated output the pipeline supports, so
27
+ * `--animation` is ignored (with a warning) for every other format. The
28
+ * boolean `animation: true` image-input path is intentionally not reachable
29
+ * from the CLI: there the object form requests `@keyframes` sampling, which
30
+ * is what `--animation` means.
31
+ */
32
+ export interface AnimationResolution {
33
+ /** Resolved `render()` animation option, or absent when disabled/ignored. */
34
+ animation?: {
35
+ duration: number;
36
+ fps: number;
37
+ loop?: number;
38
+ };
39
+ /** Human-readable reason the flag was dropped; callers warn on stderr. */
40
+ warning?: string;
41
+ }
42
+ /** Map `--animation` / `--animation-fps` / `--animation-loop` to the `render()` animation option. */
43
+ export declare function resolveAnimation(flags: CliFlags): AnimationResolution;
12
44
  /**
13
45
  * URL inputs are fetched, not read from disk. `data:` URLs stay values so
14
46
  * image inputs keep flowing through the converter pipeline.
@@ -7,6 +7,37 @@
7
7
  import fs from "node:fs/promises";
8
8
  import path from "node:path";
9
9
  import { isImageInput } from "./input.js";
10
+ /** Default frame rate for `--animation` when `--animation-fps` is omitted. */
11
+ export const DEFAULT_ANIMATION_FPS = 15;
12
+ /** Map `--animation` / `--animation-fps` / `--animation-loop` to the `render()` animation option. */
13
+ export function resolveAnimation(flags) {
14
+ if (flags.animation === undefined) {
15
+ if (flags.animationFps !== undefined) {
16
+ return { warning: "--animation-fps requires --animation; ignoring." };
17
+ }
18
+ if (flags.animationLoop !== undefined) {
19
+ return { warning: "--animation-loop requires --animation; ignoring." };
20
+ }
21
+ return {};
22
+ }
23
+ if (flags.format !== "webp") {
24
+ return {
25
+ warning: `--animation is only supported for webp output; ignoring (format=${flags.format}).`,
26
+ };
27
+ }
28
+ // `loop` stays absent unless requested: the render() default (0) is the
29
+ // historical infinite loop, so the option object keeps its old shape.
30
+ const loop = flags.animationLoop === undefined
31
+ ? undefined
32
+ : Math.max(0, Math.trunc(flags.animationLoop));
33
+ return {
34
+ animation: {
35
+ duration: flags.animation,
36
+ fps: flags.animationFps ?? DEFAULT_ANIMATION_FPS,
37
+ ...(loop === undefined ? {} : { loop }),
38
+ },
39
+ };
40
+ }
10
41
  /**
11
42
  * URL inputs are fetched, not read from disk. `data:` URLs stay values so
12
43
  * image inputs keep flowing through the converter pipeline.
@@ -44,6 +75,9 @@ export async function buildRenderOptions(input, flags) {
44
75
  const source = isUrlInput(input)
45
76
  ? { url: input }
46
77
  : await readInput(input);
78
+ const { animation, warning } = resolveAnimation(flags);
79
+ if (warning)
80
+ console.warn(`wasm-html-to-image: ${warning}`);
47
81
  return {
48
82
  width: flags.width,
49
83
  height: flags.height,
@@ -51,5 +85,6 @@ export async function buildRenderOptions(input, flags) {
51
85
  quality: flags.quality,
52
86
  ...source,
53
87
  ...(flags.baseUrl !== undefined ? { baseUrl: flags.baseUrl } : {}),
88
+ ...(animation ? { animation } : {}),
54
89
  };
55
90
  }
package/dist/cli.js CHANGED
@@ -30,6 +30,9 @@ program
30
30
  .option("-f, --format <format>", `output format (${OUTPUT_FORMATS.join(", ")})`, "png")
31
31
  .option("-q, --quality <number>", "encode quality 0-100 (encode stage only)", (v) => parseFloat(v), 85)
32
32
  .option("--base-url <url>", "base URL for relative resources (URL input: defaults to the post-redirect document URL; file input: defaults to the file's directory)")
33
+ .option("--animation <duration-ms>", "sample the document's @keyframes opacity / transform / color animation into an animated WebP; one loop duration in ms (webp output only; the CSS animation-duration wins and this value is an upper bound)", (v) => parseInt(v, 10))
34
+ .option("--animation-fps <n>", "animation frame rate (default 15, capped at ~75 frames total; requires --animation)", (v) => parseInt(v, 10))
35
+ .option("--animation-loop <n>", "how many times the animated WebP repeats (0 = infinite, the default; 1 = play once; requires --animation)", (v) => parseInt(v, 10))
33
36
  .action(async (input, options) => {
34
37
  try {
35
38
  const format = String(options.format).toLowerCase();
@@ -56,6 +59,9 @@ program
56
59
  format,
57
60
  quality: options.quality,
58
61
  baseUrl: options.baseUrl,
62
+ animation: options.animation,
63
+ animationFps: options.animationFps,
64
+ animationLoop: options.animationLoop,
59
65
  };
60
66
  const result = await render(await buildRenderOptions(input, flags));
61
67
  await fs.writeFile(outputPath, typeof result.data === "string"
@@ -13,6 +13,21 @@ export interface Orchestration {
13
13
  quality: number;
14
14
  speed: number;
15
15
  animation: boolean;
16
+ /**
17
+ * HTML animated-output request (`animation: { duration, fps, loop }`). When
18
+ * set, the HTML pipeline samples the document's `@keyframes` animation
19
+ * (opacity / transform / color) into converter frames before encoding.
20
+ */
21
+ animationFrames: {
22
+ duration: number;
23
+ fps?: number;
24
+ loop?: number;
25
+ } | undefined;
26
+ /**
27
+ * Animated WebP loop count handed to the encode stage (libwebp
28
+ * `anim_params.loop_count`). `0` = infinite (the historical default).
29
+ */
30
+ animationLoop: number;
16
31
  width: number;
17
32
  height: number | undefined;
18
33
  crop: HtmlToImageOptions["crop"];
@@ -34,7 +34,17 @@ export function defaultSpeed(format) {
34
34
  }
35
35
  /** Build per-call orchestration state (log gate, timings, diag, resolve ctx). */
36
36
  export function createOrchestration(module, options) {
37
- const { format = "png", quality = 85, speed: speedOption, animation = false, width, height, crop, fit, value, url, baseUrl, fontMap, userAgent, fallbackFonts, logLevel = LogLevel.None, onLog, diagnostics = false, onDiagnostics, resolveResource, mediaType = "screen", textToPaths, css, fonts: preloadFonts, limits = {}, pdfTitle, pdfAuthor, pdfSubject, pdfKeywords, pdfCreator, pdfProducer, pdfMargin, pdfHeader, pdfFooter, } = options;
37
+ const { format = "png", quality = 85, speed: speedOption, width, height, crop, fit, value, url, baseUrl, fontMap, userAgent, fallbackFonts, logLevel = LogLevel.None, onLog, diagnostics = false, onDiagnostics, resolveResource, mediaType = "screen", textToPaths, css, fonts: preloadFonts, limits = {}, pdfTitle, pdfAuthor, pdfSubject, pdfKeywords, pdfCreator, pdfProducer, pdfMargin, pdfHeader, pdfFooter, } = options;
38
+ // `animation` accepts a boolean (image input: keep frames) or, for HTML
39
+ // input, an object requesting keyframe sampling. The object form turns on
40
+ // the boolean flag for the encode stage and enables the frame path.
41
+ const rawAnimation = options.animation;
42
+ const animationFrames = rawAnimation !== null && typeof rawAnimation === "object"
43
+ ? rawAnimation
44
+ : undefined;
45
+ const animation = animationFrames ? true : rawAnimation === true;
46
+ // Loop count of the produced animated WebP (0 = infinite = legacy default).
47
+ const animationLoop = Math.max(0, Math.trunc(animationFrames?.loop ?? 0));
38
48
  const speed = speedOption ?? defaultSpeed(format);
39
49
  const fmtInt = FORMAT_INT[format];
40
50
  const mediaTypeInt = mediaType === "print" ? 1 : 0;
@@ -128,6 +138,8 @@ export function createOrchestration(module, options) {
128
138
  quality,
129
139
  speed,
130
140
  animation,
141
+ animationFrames,
142
+ animationLoop,
131
143
  width,
132
144
  height,
133
145
  crop,
@@ -228,7 +240,7 @@ export async function resolveHtmlSource(orch) {
228
240
  * `imageBytesFromUnknown` (`unknown` in, no `as` casts).
229
241
  */
230
242
  export async function runImageInput(module, value, orch) {
231
- const { format, quality, speed, animation, width, height, crop, fit, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, } = orch;
243
+ const { format, quality, speed, animation, animationLoop, width, height, crop, fit, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, } = orch;
232
244
  // Narrowed here: caller selected this branch via `isImageInput(value)`.
233
245
  const loadImage = module.converter_load_image;
234
246
  if (typeof loadImage !== "function")
@@ -323,7 +335,7 @@ export async function runImageInput(module, value, orch) {
323
335
  if (typeof encodeBinding !== "function")
324
336
  throw loggedBinding("converter_encode");
325
337
  const encodeStart = now();
326
- const out = (await encodeBinding(inst, fmtInt, quality, speed, animation));
338
+ const out = (await encodeBinding(inst, fmtInt, quality, speed, animation, animationLoop));
327
339
  addTime("encode", now() - encodeStart);
328
340
  if (out == null) {
329
341
  throw logged("wasm-html-to-image: failed to encode image");
@@ -355,7 +367,7 @@ export async function runImageInput(module, value, orch) {
355
367
  * pre-resolved resources, so it stays a fallback only).
356
368
  */
357
369
  export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontMap, orch) {
358
- const { format, quality, speed, animation, width, height, baseUrl, fontMap, userAgent, fallbackFonts, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, resolveCtx, } = orch;
370
+ const { format, quality, speed, animation, animationFrames, animationLoop, width, height, baseUrl, fontMap, userAgent, fallbackFonts, fmtInt, t0, emitLog, logged, loggedBinding, addTime, deliverReport, resolveCtx, } = orch;
359
371
  const renderBinding = module.satoru_render;
360
372
  const encodeBinding = module.converter_encode;
361
373
  if (typeof renderBinding !== "function" ||
@@ -402,8 +414,22 @@ export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontM
402
414
  const renderStart = now();
403
415
  const cInst = module.converter_create_instance();
404
416
  try {
417
+ // Animated HTML: sample the document's @keyframes animation (opacity /
418
+ // transform / color) straight into the converter's frames (additive).
419
+ // Falls back to the single-frame path when no frames were produced.
420
+ let renderedFrames = false;
421
+ const renderAnimationToConverter = module.satoru_render_animation_to_converter;
422
+ if (animationFrames &&
423
+ typeof renderAnimationToConverter === "function") {
424
+ const frames = (await renderAnimationToConverter(sInst, cInst, htmls, width, height ?? 0, satoruOpts, animationFrames.duration, animationFrames.fps ?? 15));
425
+ addTime("render", now() - renderStart);
426
+ renderedFrames = frames > 0;
427
+ }
405
428
  const renderToConverter = module.satoru_render_to_converter;
406
- if (typeof renderToConverter === "function") {
429
+ if (renderedFrames) {
430
+ // converter already holds the sampled frames.
431
+ }
432
+ else if (typeof renderToConverter === "function") {
407
433
  // Bitmap goes straight into the converter: no PNG encode/decode.
408
434
  const ok = await renderToConverter(sInst, cInst, htmls, width, height ?? 0, satoruOpts);
409
435
  addTime("render", now() - renderStart);
@@ -423,14 +449,16 @@ export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontM
423
449
  }
424
450
  const outWidth = module.converter_get_width?.(cInst) ?? width;
425
451
  const outHeight = module.converter_get_height?.(cInst) ?? height ?? 0;
426
- const outAnimation = module.converter_is_animation?.(cInst) ?? animation;
427
- const outFormat = module.converter_get_format?.(cInst) || format;
428
452
  const encodeStart = now();
429
- const out = (await encodeBinding(cInst, fmtInt, quality, speed, animation));
453
+ const out = (await encodeBinding(cInst, fmtInt, quality, speed, animation, animationLoop));
430
454
  addTime("encode", now() - encodeStart);
431
455
  if (out == null) {
432
456
  throw logged("wasm-html-to-image: failed to encode image");
433
457
  }
458
+ // Read the post-encode metadata: `is_animation`/`get_format` are only
459
+ // meaningful after `converter_encode` has run.
460
+ const outAnimation = module.converter_is_animation?.(cInst) ?? animation;
461
+ const outFormat = module.converter_get_format?.(cInst) || format;
434
462
  addTime("total", now() - t0);
435
463
  emitLog(LogLevel.Info, `render done: format=${format}`);
436
464
  deliverReport();
package/dist/core.d.ts CHANGED
@@ -65,8 +65,31 @@ export interface HtmlToImageOptions extends RenderOptions {
65
65
  quality?: number;
66
66
  /** Encode speed 0-10, mainly for AVIF (encode stage only; default 8 for AVIF, 6 otherwise). */
67
67
  speed?: number;
68
- /** Keep animation frames where supported (encode stage only). */
69
- animation?: boolean;
68
+ /**
69
+ * Animation handling.
70
+ * - `true`: keep animation frames where supported (image input / encode stage).
71
+ * - `{ duration, fps, loop }` (HTML input only): sample the document's
72
+ * `@keyframes` animation into frames. `duration` is one loop length in ms
73
+ * (clamped to 5000), `fps` defaults to 15 (frame count capped at ~75).
74
+ * The CSS `animation-duration` (including the `animation` shorthand)
75
+ * is the length of one iteration; `animation-delay` /
76
+ * `animation-iteration-count` define the sampled timeline, capped by
77
+ * `duration`; `fps` is the upper frame rate. `loop` is how many times the
78
+ * animated WebP repeats: `0` (the default) means infinite, `1` means play
79
+ * once. `opacity`, `transform` (`translate` / `rotate` / `scale` only) and
80
+ * `color` / `background-color` keyframes are animated; their values may
81
+ * use `var()` (resolved against the element's custom properties).
82
+ * `animation-timing-function` (per keyframe or element-level:
83
+ * `linear` / `ease*` / `cubic-bezier()` / `steps()` / `step-*`),
84
+ * `animation-direction` and `animation-fill-mode` are honoured.
85
+ * Several animations on one element (`animation-name: a, b`) are out of
86
+ * scope.
87
+ */
88
+ animation?: boolean | {
89
+ duration: number;
90
+ fps?: number;
91
+ loop?: number;
92
+ };
70
93
  /** Crop rectangle (encode stage for image input, render options for HTML). */
71
94
  crop?: {
72
95
  x: number;