wasm-html-to-image 1.0.1 → 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
@@ -267,8 +267,18 @@ npx wasm-html-to-image template.html -o banner.webp -w 1200 -h 630 -f webp -q 90
267
267
  # Capture screenshot from URL
268
268
  npx wasm-html-to-image https://example.com -o site.png -w 1280 -h 720
269
269
 
270
+ # Pin the base for relative resources (default: URL input follows redirects,
271
+ # file input uses the file's own directory)
272
+ npx wasm-html-to-image https://example.com/news -o news.png --base-url https://example.com/
273
+
270
274
  # Convert and resize an image
271
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
272
282
  ```
273
283
 
274
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`).
@@ -0,0 +1,64 @@
1
+ import type { HtmlToImageOptions, OutputFormat } from "./core.js";
2
+ /** Flags as parsed by commander (see `./cli.ts`). */
3
+ export interface CliFlags {
4
+ output?: string;
5
+ width: number;
6
+ height?: number;
7
+ format: OutputFormat;
8
+ quality: number;
9
+ /** Explicit `--base-url`; absent means "let the source decide". */
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;
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;
44
+ /**
45
+ * URL inputs are fetched, not read from disk. `data:` URLs stay values so
46
+ * image inputs keep flowing through the converter pipeline.
47
+ */
48
+ export declare function isUrlInput(input: string): boolean;
49
+ /**
50
+ * Read a local input file as binary; image files (magic-byte sniffed) are
51
+ * passed through as bytes, everything else as UTF-8 text (HTML).
52
+ */
53
+ export declare function readInput(input: string): Promise<{
54
+ value: string | Uint8Array;
55
+ baseUrl: string;
56
+ }>;
57
+ /**
58
+ * Map CLI flags to `render()` options.
59
+ *
60
+ * `--base-url` is the only way to pin the base: without it a URL input leaves
61
+ * `baseUrl` unset so relative resources follow the post-redirect final URL
62
+ * (`resolveHtmlSource`), while a file input falls back to its own directory.
63
+ */
64
+ export declare function buildRenderOptions(input: string, flags: CliFlags): Promise<HtmlToImageOptions>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * CLI flag plumbing split from `./cli.ts` (behavior unchanged): input-type
3
+ * detection, local file reading, and the flag -> `render()` option mapping.
4
+ * Kept separate from the executable entry point so the mapping is
5
+ * unit-testable without running the CLI.
6
+ */
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
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
+ }
41
+ /**
42
+ * URL inputs are fetched, not read from disk. `data:` URLs stay values so
43
+ * image inputs keep flowing through the converter pipeline.
44
+ */
45
+ export function isUrlInput(input) {
46
+ const isWindowsPath = /^[a-zA-Z]:[\\/]/.test(input);
47
+ return (!isWindowsPath &&
48
+ /^[a-z][a-z0-9+.-]*:/i.test(input) &&
49
+ !input.startsWith("data:"));
50
+ }
51
+ /**
52
+ * Read a local input file as binary; image files (magic-byte sniffed) are
53
+ * passed through as bytes, everything else as UTF-8 text (HTML).
54
+ */
55
+ export async function readInput(input) {
56
+ const data = await fs.readFile(input);
57
+ let value = data.toString("utf-8");
58
+ try {
59
+ if (isImageInput(data))
60
+ value = data;
61
+ }
62
+ catch {
63
+ // Unknown binary magic -> treat as text and let rendering decide.
64
+ }
65
+ return { value, baseUrl: path.dirname(path.resolve(input)) };
66
+ }
67
+ /**
68
+ * Map CLI flags to `render()` options.
69
+ *
70
+ * `--base-url` is the only way to pin the base: without it a URL input leaves
71
+ * `baseUrl` unset so relative resources follow the post-redirect final URL
72
+ * (`resolveHtmlSource`), while a file input falls back to its own directory.
73
+ */
74
+ export async function buildRenderOptions(input, flags) {
75
+ const source = isUrlInput(input)
76
+ ? { url: input }
77
+ : await readInput(input);
78
+ const { animation, warning } = resolveAnimation(flags);
79
+ if (warning)
80
+ console.warn(`wasm-html-to-image: ${warning}`);
81
+ return {
82
+ width: flags.width,
83
+ height: flags.height,
84
+ format: flags.format,
85
+ quality: flags.quality,
86
+ ...source,
87
+ ...(flags.baseUrl !== undefined ? { baseUrl: flags.baseUrl } : {}),
88
+ ...(animation ? { animation } : {}),
89
+ };
90
+ }
package/dist/cli.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { program } from "commander";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { render, } from "./single.js";
6
- import { isImageInput } from "./core.js";
5
+ import { render } from "./single.js";
6
+ import { buildRenderOptions, isUrlInput } from "./cli-options.js";
7
7
  const OUTPUT_FORMATS = [
8
8
  "svg",
9
9
  "png",
@@ -19,22 +19,6 @@ const OUTPUT_FORMATS = [
19
19
  function isOutputFormat(value) {
20
20
  return OUTPUT_FORMATS.includes(value);
21
21
  }
22
- /**
23
- * Read a local input file as binary; image files (magic-byte sniffed) are
24
- * passed through as bytes, everything else as UTF-8 text (HTML).
25
- */
26
- async function readInput(input) {
27
- const data = await fs.readFile(input);
28
- let value = data.toString("utf-8");
29
- try {
30
- if (isImageInput(data))
31
- value = data;
32
- }
33
- catch {
34
- // Unknown binary magic -> treat as text and let rendering decide.
35
- }
36
- return { value, baseUrl: path.dirname(path.resolve(input)) };
37
- }
38
22
  program
39
23
  .name("wasm-html-to-image")
40
24
  .description("HTML to image converter on the single unified WASM module (svg/pdf direct, png/jpeg/webp/avif/jxl/raw/thumbhash via encode or html_to_image)")
@@ -45,6 +29,10 @@ program
45
29
  .option("-h, --height <number>", "viewport height (omit for auto)", (v) => parseInt(v, 10))
46
30
  .option("-f, --format <format>", `output format (${OUTPUT_FORMATS.join(", ")})`, "png")
47
31
  .option("-q, --quality <number>", "encode quality 0-100 (encode stage only)", (v) => parseFloat(v), 85)
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))
48
36
  .action(async (input, options) => {
49
37
  try {
50
38
  const format = String(options.format).toLowerCase();
@@ -52,10 +40,7 @@ program
52
40
  console.error(`Error: unsupported format "${options.format}". Choose from: ${OUTPUT_FORMATS.join(", ")}`);
53
41
  process.exit(1);
54
42
  }
55
- const isWindowsPath = /^[a-zA-Z]:[\\/]/.test(input);
56
- const isUrl = !isWindowsPath &&
57
- /^[a-z][a-z0-9+.-]*:/i.test(input) &&
58
- !input.startsWith("data:");
43
+ const isUrl = isUrlInput(input);
59
44
  let outputPath = options.output;
60
45
  if (!outputPath) {
61
46
  const ext = format;
@@ -67,14 +52,18 @@ program
67
52
  ext: `.${ext}`,
68
53
  });
69
54
  }
70
- const renderOptions = {
55
+ const flags = {
56
+ output: options.output,
71
57
  width: options.width,
72
58
  height: options.height,
73
59
  format,
74
60
  quality: options.quality,
75
- ...(isUrl ? { url: input, baseUrl: input } : await readInput(input)),
61
+ baseUrl: options.baseUrl,
62
+ animation: options.animation,
63
+ animationFps: options.animationFps,
64
+ animationLoop: options.animationLoop,
76
65
  };
77
- const result = await render(renderOptions);
66
+ const result = await render(await buildRenderOptions(input, flags));
78
67
  await fs.writeFile(outputPath, typeof result.data === "string"
79
68
  ? result.data
80
69
  : Buffer.from(result.data));
@@ -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,
@@ -183,12 +195,18 @@ export async function resolveHtmlSource(orch) {
183
195
  if (typeof url === "string") {
184
196
  const headers = { "User-Agent": userAgent ?? DEFAULT_USER_AGENT };
185
197
  const fetchStart = now();
198
+ // Post-redirect document URL (`Response.url`). Relative resources must
199
+ // resolve against the final URL, not the stale request URL: a 301 to a new
200
+ // host otherwise leaves every stylesheet/font/image on the old host.
201
+ let finalUrl;
186
202
  let htmls;
187
203
  if (resolveResource) {
188
204
  const fallbackFetch = async () => {
189
205
  const res = await fetch(url, { headers });
190
206
  if (!res.ok)
191
207
  return null;
208
+ if (res.url)
209
+ finalUrl = res.url;
192
210
  return toBytes(await res.arrayBuffer());
193
211
  };
194
212
  const raw = await resolveResource(url, fallbackFetch);
@@ -202,8 +220,15 @@ export async function resolveHtmlSource(orch) {
202
220
  if (!res.ok) {
203
221
  throw logged(`wasm-html-to-image: failed to fetch HTML from URL: ${url} (${res.status})`);
204
222
  }
223
+ if (res.url)
224
+ finalUrl = res.url;
205
225
  htmls = await res.text();
206
226
  }
227
+ // An explicit `baseUrl` always wins; otherwise adopt the document URL so
228
+ // relative resource resolution follows redirects.
229
+ if (orch.baseUrl === undefined && finalUrl !== undefined) {
230
+ orch.baseUrl = finalUrl;
231
+ }
207
232
  addTime("fetchHtml", now() - fetchStart);
208
233
  return htmls;
209
234
  }
@@ -215,7 +240,7 @@ export async function resolveHtmlSource(orch) {
215
240
  * `imageBytesFromUnknown` (`unknown` in, no `as` casts).
216
241
  */
217
242
  export async function runImageInput(module, value, orch) {
218
- 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;
219
244
  // Narrowed here: caller selected this branch via `isImageInput(value)`.
220
245
  const loadImage = module.converter_load_image;
221
246
  if (typeof loadImage !== "function")
@@ -310,7 +335,7 @@ export async function runImageInput(module, value, orch) {
310
335
  if (typeof encodeBinding !== "function")
311
336
  throw loggedBinding("converter_encode");
312
337
  const encodeStart = now();
313
- const out = (await encodeBinding(inst, fmtInt, quality, speed, animation));
338
+ const out = (await encodeBinding(inst, fmtInt, quality, speed, animation, animationLoop));
314
339
  addTime("encode", now() - encodeStart);
315
340
  if (out == null) {
316
341
  throw logged("wasm-html-to-image: failed to encode image");
@@ -342,7 +367,7 @@ export async function runImageInput(module, value, orch) {
342
367
  * pre-resolved resources, so it stays a fallback only).
343
368
  */
344
369
  export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontMap, orch) {
345
- 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;
346
371
  const renderBinding = module.satoru_render;
347
372
  const encodeBinding = module.converter_encode;
348
373
  if (typeof renderBinding !== "function" ||
@@ -389,8 +414,22 @@ export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontM
389
414
  const renderStart = now();
390
415
  const cInst = module.converter_create_instance();
391
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
+ }
392
428
  const renderToConverter = module.satoru_render_to_converter;
393
- if (typeof renderToConverter === "function") {
429
+ if (renderedFrames) {
430
+ // converter already holds the sampled frames.
431
+ }
432
+ else if (typeof renderToConverter === "function") {
394
433
  // Bitmap goes straight into the converter: no PNG encode/decode.
395
434
  const ok = await renderToConverter(sInst, cInst, htmls, width, height ?? 0, satoruOpts);
396
435
  addTime("render", now() - renderStart);
@@ -410,14 +449,16 @@ export async function tryRunHtmlInstance(module, htmls, satoruOpts, defaultFontM
410
449
  }
411
450
  const outWidth = module.converter_get_width?.(cInst) ?? width;
412
451
  const outHeight = module.converter_get_height?.(cInst) ?? height ?? 0;
413
- const outAnimation = module.converter_is_animation?.(cInst) ?? animation;
414
- const outFormat = module.converter_get_format?.(cInst) || format;
415
452
  const encodeStart = now();
416
- const out = (await encodeBinding(cInst, fmtInt, quality, speed, animation));
453
+ const out = (await encodeBinding(cInst, fmtInt, quality, speed, animation, animationLoop));
417
454
  addTime("encode", now() - encodeStart);
418
455
  if (out == null) {
419
456
  throw logged("wasm-html-to-image: failed to encode image");
420
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;
421
462
  addTime("total", now() - t0);
422
463
  emitLog(LogLevel.Info, `render done: format=${format}`);
423
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;