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 +10 -0
- package/dist/browser-workers.js +20 -7
- package/dist/cli-options.d.ts +64 -0
- package/dist/cli-options.js +90 -0
- package/dist/cli.js +14 -25
- package/dist/core-orchestrate.d.ts +15 -0
- package/dist/core-orchestrate.js +49 -8
- package/dist/core.d.ts +25 -2
- package/dist/html-to-image-wasm.js +3 -1
- package/dist/html-to-image.wasm +0 -0
- package/dist/loader.d.ts +30 -2
- package/dist/loader.js +172 -11
- package/dist/wasm-binary.d.ts +34 -0
- package/dist/wasm-binary.js +210 -0
- package/dist/wasm-payload.js +8 -0
- package/dist/web-workers.js +341 -22
- package/llms.txt +3 -3
- package/package.json +1 -1
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
|
---
|
package/dist/browser-workers.js
CHANGED
|
@@ -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
|
|
9
|
-
/**
|
|
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 (!
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
6
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
61
|
+
baseUrl: options.baseUrl,
|
|
62
|
+
animation: options.animation,
|
|
63
|
+
animationFps: options.animationFps,
|
|
64
|
+
animationLoop: options.animationLoop,
|
|
76
65
|
};
|
|
77
|
-
const result = await render(
|
|
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"];
|
package/dist/core-orchestrate.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
-
/**
|
|
69
|
-
|
|
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;
|