polotno-node 3.0.0 → 3.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.
- package/LICENSE.md +10 -0
- package/README.md +22 -30
- package/dist/assets/{html2canvas-CfrNTgDn.js → html2canvas-BeKaVBkZ.js} +1 -1
- package/dist/assets/index-DFqP22V0.js +540 -0
- package/dist/assets/{index.es-C7BBG6KT.js → index.es-DwOGAynS.js} +1 -1
- package/dist/assets/{jspdf.es.min-D1g0nHTs.js → jspdf.es.min-BBVOPmzA.js} +4 -4
- package/dist/assets/lib-CuK_SezV.js +65 -0
- package/dist/index.html +1 -1
- package/lib/font-cache.d.ts +3 -0
- package/lib/font-cache.js +99 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +121 -0
- package/lib/instance.d.ts +41 -0
- package/lib/instance.js +740 -0
- package/lib/types.d.ts +76 -0
- package/lib/types.js +1 -0
- package/package.json +36 -20
- package/dist/assets/index-oYy3TsOk.js +0 -464
- package/dist/assets/lib-U0NRl5MI.js +0 -65
- package/font-cache.js +0 -109
- package/index.d.ts +0 -147
- package/index.js +0 -161
- package/instance.js +0 -863
package/lib/instance.js
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
import { pathToFileURL, fileURLToPath } from "url";
|
|
7
|
+
import { Readable } from "stream";
|
|
8
|
+
import { pipeline } from "stream/promises";
|
|
9
|
+
import { assertValidDesign } from "@polotno/schema";
|
|
10
|
+
import * as fontCache from "./font-cache.js";
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
// Compiled to lib/instance.js; the client bundle is published to ../dist.
|
|
13
|
+
const DEFAULT_CLIENT = `file:${path.join(__dirname, "..", "dist", "index.html")}`;
|
|
14
|
+
// Walk Polotno nodes recursively (depth-first)
|
|
15
|
+
// cb(node) may return true to stop traversing the current branch early
|
|
16
|
+
const forEveryChild = (node, cb) => {
|
|
17
|
+
if (!node || !node.children)
|
|
18
|
+
return;
|
|
19
|
+
for (const child of node.children) {
|
|
20
|
+
const shouldStop = cb(child);
|
|
21
|
+
if (shouldStop === true) {
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
forEveryChild(child, cb);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
// Collect pointers to all media objects that can have `src` rewritten.
|
|
28
|
+
// Returned entries look like: { kind: 'video'|'audio', target: <object>, key: 'src' }
|
|
29
|
+
const collectMediaSourcePointers = (json) => {
|
|
30
|
+
const pointers = [];
|
|
31
|
+
// videos in elements tree
|
|
32
|
+
for (const page of json?.pages || []) {
|
|
33
|
+
for (const el of page?.children || []) {
|
|
34
|
+
if (el?.type === "video" && typeof el.src === "string") {
|
|
35
|
+
pointers.push({ kind: "video", target: el, key: "src" });
|
|
36
|
+
}
|
|
37
|
+
forEveryChild(el, (child) => {
|
|
38
|
+
if (child?.type === "video" && typeof child.src === "string") {
|
|
39
|
+
pointers.push({ kind: "video", target: child, key: "src" });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// audios array
|
|
45
|
+
for (const audio of json?.audios || []) {
|
|
46
|
+
if (audio && typeof audio.src === "string") {
|
|
47
|
+
pointers.push({ kind: "audio", target: audio, key: "src" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return pointers;
|
|
51
|
+
};
|
|
52
|
+
const isDataOrFileSrc = (src) => {
|
|
53
|
+
if (typeof src !== "string")
|
|
54
|
+
return true;
|
|
55
|
+
return (src.startsWith("data:") ||
|
|
56
|
+
src.startsWith("file:") ||
|
|
57
|
+
src.startsWith("blob:"));
|
|
58
|
+
};
|
|
59
|
+
const guessExtension = (urlString, contentType) => {
|
|
60
|
+
try {
|
|
61
|
+
const u = new URL(urlString);
|
|
62
|
+
const ext = path.extname(u.pathname || "");
|
|
63
|
+
if (ext)
|
|
64
|
+
return ext;
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
// ignore; fallback to content-type
|
|
68
|
+
}
|
|
69
|
+
const ct = String(contentType || "")
|
|
70
|
+
.split(";")[0]
|
|
71
|
+
.trim()
|
|
72
|
+
.toLowerCase();
|
|
73
|
+
const map = {
|
|
74
|
+
"video/mp4": ".mp4",
|
|
75
|
+
"video/webm": ".webm",
|
|
76
|
+
"video/quicktime": ".mov",
|
|
77
|
+
"audio/mpeg": ".mp3",
|
|
78
|
+
"audio/mp4": ".m4a",
|
|
79
|
+
"audio/wav": ".wav",
|
|
80
|
+
"audio/x-wav": ".wav",
|
|
81
|
+
"audio/ogg": ".ogg",
|
|
82
|
+
"audio/webm": ".webm",
|
|
83
|
+
};
|
|
84
|
+
return map[ct] || ".bin";
|
|
85
|
+
};
|
|
86
|
+
const downloadUrlToFile = async (urlString, destinationPath) => {
|
|
87
|
+
if (typeof fetch !== "function") {
|
|
88
|
+
throw new Error("Global fetch API is not available in this Node.js runtime. Please use Node 18+.");
|
|
89
|
+
}
|
|
90
|
+
const MAX_ATTEMPTS = 3;
|
|
91
|
+
const BASE_DELAY_MS = 300;
|
|
92
|
+
let lastError = null;
|
|
93
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
94
|
+
try {
|
|
95
|
+
// Ensure no partial file exists from previous attempts
|
|
96
|
+
await fs.promises.rm(destinationPath, { force: true });
|
|
97
|
+
const res = await fetch(urlString);
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
// Best-effort read of error body (small) for debugging.
|
|
100
|
+
let bodyPreview = "";
|
|
101
|
+
try {
|
|
102
|
+
bodyPreview = await res.text();
|
|
103
|
+
if (bodyPreview.length > 500) {
|
|
104
|
+
bodyPreview = bodyPreview.slice(0, 500) + "...";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
const details = [
|
|
111
|
+
`${res.status} ${res.statusText}`.trim(),
|
|
112
|
+
bodyPreview ? `body: ${JSON.stringify(bodyPreview)}` : null,
|
|
113
|
+
]
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join(", ");
|
|
116
|
+
throw new Error(`HTTP error while downloading: ${details}`);
|
|
117
|
+
}
|
|
118
|
+
if (!res.body) {
|
|
119
|
+
throw new Error("Empty response body");
|
|
120
|
+
}
|
|
121
|
+
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destinationPath));
|
|
122
|
+
return res.headers.get("content-type") || "";
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
lastError = e;
|
|
126
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
127
|
+
const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
128
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const message = lastError && lastError.message ? lastError.message : String(lastError);
|
|
134
|
+
throw new Error(`Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`);
|
|
135
|
+
};
|
|
136
|
+
// Returns a human-readable reason string if the video file needs transcoding
|
|
137
|
+
// for Chromium headless compatibility, or null if it is already render-safe.
|
|
138
|
+
const getVideoNormalizationReason = (filePath) => {
|
|
139
|
+
try {
|
|
140
|
+
const raw = execSync(`ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,codec_tag_string,pix_fmt -of json "${filePath}"`, { encoding: "utf8" });
|
|
141
|
+
const info = JSON.parse(raw);
|
|
142
|
+
const vs = info?.streams?.[0];
|
|
143
|
+
if (!vs)
|
|
144
|
+
return null;
|
|
145
|
+
const codec = (vs.codec_name || "").toLowerCase();
|
|
146
|
+
const tag = (vs.codec_tag_string || "").toLowerCase();
|
|
147
|
+
if (["hevc", "h265"].includes(codec) || ["hvc1", "hev1"].includes(tag)) {
|
|
148
|
+
return "HEVC/H.265 is not supported by Chromium headless";
|
|
149
|
+
}
|
|
150
|
+
const pix = (vs.pix_fmt || "").toLowerCase();
|
|
151
|
+
if (pix && pix !== "yuv420p") {
|
|
152
|
+
return `pixel format "${pix}" is not the preferred yuv420p`;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const isFfmpegAvailable = () => {
|
|
161
|
+
try {
|
|
162
|
+
execSync("ffmpeg -version", { stdio: "ignore" });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const getAudioCodec = (filePath) => {
|
|
170
|
+
try {
|
|
171
|
+
const result = execSync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`, { encoding: "utf8" });
|
|
172
|
+
return result.trim();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const convertAudioToAac = (inputPath, outputPath) => {
|
|
179
|
+
execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
|
|
180
|
+
stdio: "ignore",
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
const prepareLocalMediaForVideoExport = async (json) => {
|
|
184
|
+
const clonedJson = JSON.parse(JSON.stringify(json));
|
|
185
|
+
const pointers = collectMediaSourcePointers(clonedJson);
|
|
186
|
+
const rewriteMap = new Map();
|
|
187
|
+
const urlsToDownload = [];
|
|
188
|
+
const videoUrls = new Set();
|
|
189
|
+
for (const p of pointers) {
|
|
190
|
+
const src = p.target[p.key];
|
|
191
|
+
if (isDataOrFileSrc(src))
|
|
192
|
+
continue;
|
|
193
|
+
try {
|
|
194
|
+
const u = new URL(src);
|
|
195
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!rewriteMap.has(src)) {
|
|
202
|
+
rewriteMap.set(src, null);
|
|
203
|
+
urlsToDownload.push(src);
|
|
204
|
+
}
|
|
205
|
+
if (p.kind === "video")
|
|
206
|
+
videoUrls.add(src);
|
|
207
|
+
}
|
|
208
|
+
if (urlsToDownload.length === 0) {
|
|
209
|
+
return { json: clonedJson, cleanup: async () => { } };
|
|
210
|
+
}
|
|
211
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "polotno-node-assets-"));
|
|
212
|
+
let didCleanup = false;
|
|
213
|
+
const cleanup = async () => {
|
|
214
|
+
if (didCleanup)
|
|
215
|
+
return;
|
|
216
|
+
didCleanup = true;
|
|
217
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
await Promise.all(urlsToDownload.map(async (src) => {
|
|
221
|
+
const tmpBase = crypto.randomUUID
|
|
222
|
+
? crypto.randomUUID()
|
|
223
|
+
: crypto.randomBytes(16).toString("hex");
|
|
224
|
+
const tmpPath = path.join(tempDir, tmpBase);
|
|
225
|
+
const contentType = await downloadUrlToFile(src, tmpPath);
|
|
226
|
+
const ext = guessExtension(src, contentType);
|
|
227
|
+
let finalPath = tmpPath + ext;
|
|
228
|
+
await fs.promises.rename(tmpPath, finalPath);
|
|
229
|
+
if (videoUrls.has(src)) {
|
|
230
|
+
const reason = getVideoNormalizationReason(finalPath);
|
|
231
|
+
if (reason) {
|
|
232
|
+
const normalizedPath = finalPath + ".normalized.mp4";
|
|
233
|
+
execSync(`ffmpeg -y -i "${finalPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${normalizedPath}"`, { stdio: "ignore" });
|
|
234
|
+
finalPath = normalizedPath;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
rewriteMap.set(src, pathToFileURL(finalPath).toString());
|
|
238
|
+
}));
|
|
239
|
+
for (const p of pointers) {
|
|
240
|
+
const src = p.target[p.key];
|
|
241
|
+
const replacement = rewriteMap.get(src);
|
|
242
|
+
if (replacement) {
|
|
243
|
+
p.target[p.key] = replacement;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { json: clonedJson, cleanup };
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
await cleanup();
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
export const createPage = async (browser, url, requestInterceptor, useFontCache = true) => {
|
|
254
|
+
const page = await browser.newPage();
|
|
255
|
+
// Attach request interceptor if provided
|
|
256
|
+
if (useFontCache || typeof requestInterceptor === "function") {
|
|
257
|
+
await page.setRequestInterception(true);
|
|
258
|
+
page.on("request", (request) => {
|
|
259
|
+
if (useFontCache && fontCache.tryRespond(request)) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (typeof requestInterceptor === "function") {
|
|
263
|
+
requestInterceptor(request);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
request.continue().catch(() => { });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
if (useFontCache) {
|
|
270
|
+
page.on("response", (response) => fontCache.capture(response));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// we need to make sure that headless is inside user agent
|
|
274
|
+
// because we use it for key validation check
|
|
275
|
+
// but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
|
|
276
|
+
// so some languages may not work
|
|
277
|
+
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
278
|
+
if (userAgent.indexOf("Headless") === -1) {
|
|
279
|
+
await page.setUserAgent(userAgent + " Chrome Headless");
|
|
280
|
+
}
|
|
281
|
+
page.on("console", (msg) => {
|
|
282
|
+
msg.args().forEach(async (message) => {
|
|
283
|
+
// skip style information
|
|
284
|
+
if (message.toString().indexOf("margin") >= 0) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const { subtype, description } = message.remoteObject();
|
|
288
|
+
if (subtype === "error") {
|
|
289
|
+
console.error(description);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const text = message
|
|
293
|
+
.toString()
|
|
294
|
+
.replace("JSHandle:", "")
|
|
295
|
+
.replace("%c", "");
|
|
296
|
+
console.log(text);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
page.on("pageerror", (msg) => {
|
|
300
|
+
console.error(msg);
|
|
301
|
+
});
|
|
302
|
+
await page.goto(url);
|
|
303
|
+
return page;
|
|
304
|
+
};
|
|
305
|
+
export const run = async (page, func, args) => {
|
|
306
|
+
return await page.evaluate(func, ...args);
|
|
307
|
+
};
|
|
308
|
+
// In-page export recipes. These run inside the browser (via page.evaluate / run),
|
|
309
|
+
// so they may only reference page globals (`store`, `window`) — not module scope.
|
|
310
|
+
// Defining them once lets the instance methods and the module-level page-taking
|
|
311
|
+
// exports share a single recipe per export format.
|
|
312
|
+
const recipeToDataURL = async (json, attrs) => {
|
|
313
|
+
const pixelRatio = attrs.pixelRatio || 1;
|
|
314
|
+
store.loadJSON(json);
|
|
315
|
+
store.setElementsPixelRatio(pixelRatio);
|
|
316
|
+
await store.waitLoading();
|
|
317
|
+
return store.toDataURL({ ...attrs, pixelRatio });
|
|
318
|
+
};
|
|
319
|
+
const recipeToPDFDataURL = async (json, attrs) => {
|
|
320
|
+
store.loadJSON(json);
|
|
321
|
+
// Honor an `onProgress` callback exposed by the instance `run()`. When it is
|
|
322
|
+
// not exposed (e.g. the module-level page-taking export) this is a no-op.
|
|
323
|
+
if (window.onProgress) {
|
|
324
|
+
attrs.onProgress = (progress) => {
|
|
325
|
+
window.onProgress(progress);
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
await store.waitLoading();
|
|
329
|
+
return await store.toPDFDataURL(attrs);
|
|
330
|
+
};
|
|
331
|
+
const recipeToBlob = async (json, attrs) => {
|
|
332
|
+
store.loadJSON(json);
|
|
333
|
+
await store.waitLoading();
|
|
334
|
+
return await store.toBlob(attrs);
|
|
335
|
+
};
|
|
336
|
+
const recipeToGIFDataURL = async (json, attrs) => {
|
|
337
|
+
store.loadJSON(json);
|
|
338
|
+
return await store.toGIFDataURL(attrs);
|
|
339
|
+
};
|
|
340
|
+
export const jsonToDataURL = async (page, json, attrs) => {
|
|
341
|
+
return await page.evaluate(recipeToDataURL, json, attrs || {});
|
|
342
|
+
};
|
|
343
|
+
export const jsonToPDFDataURL = async (page, json, attrs) => {
|
|
344
|
+
return page.evaluate(recipeToPDFDataURL, json, attrs || {});
|
|
345
|
+
};
|
|
346
|
+
export const jsonToBlob = async (page, json, attrs) => {
|
|
347
|
+
return page.evaluate(recipeToBlob, json, attrs || {});
|
|
348
|
+
};
|
|
349
|
+
// Render options that map directly to a `window.config.*` setter on the client.
|
|
350
|
+
// `arg: "value"` passes the option value through whenever it is explicitly set
|
|
351
|
+
// (including `false`, so a client-side default can be turned off). `arg: true`
|
|
352
|
+
// is an enable-only flag: the setter is called with `true` only when the option
|
|
353
|
+
// is truthy (passing `false` is a no-op, leaving the client default in place).
|
|
354
|
+
const CONFIG_OPTION_SETTERS = [
|
|
355
|
+
{ option: "assetLoadTimeout", method: "setAssetLoadTimeout", arg: "value" },
|
|
356
|
+
{ option: "fontLoadTimeout", method: "setFontLoadTimeout", arg: "value" },
|
|
357
|
+
{ option: "legacyRichTextEnabled", method: "setLegacyRichTextEnabled", arg: true },
|
|
358
|
+
{ option: "textVerticalResizeEnabled", method: "setTextVerticalResizeEnabled", arg: "value" },
|
|
359
|
+
{ option: "textSplitAllowed", method: "unstable_setTextSplitAllowed", arg: true },
|
|
360
|
+
{ option: "textOverflow", method: "setTextOverflow", arg: "value" },
|
|
361
|
+
];
|
|
362
|
+
// Apply the option→config setters above to a page in a single round-trip.
|
|
363
|
+
const applyConfigOptions = async (page, attrs = {}) => {
|
|
364
|
+
const calls = [];
|
|
365
|
+
for (const { option, method, arg } of CONFIG_OPTION_SETTERS) {
|
|
366
|
+
if (arg === "value") {
|
|
367
|
+
// value passthrough — apply on any explicit value, including `false`
|
|
368
|
+
if (attrs[option] !== undefined)
|
|
369
|
+
calls.push({ method, value: attrs[option] });
|
|
370
|
+
}
|
|
371
|
+
else if (attrs[option]) {
|
|
372
|
+
// enable-only flag
|
|
373
|
+
calls.push({ method, value: arg });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (calls.length === 0)
|
|
377
|
+
return;
|
|
378
|
+
await page.evaluate((calls) => {
|
|
379
|
+
for (const { method, value } of calls) {
|
|
380
|
+
if (window.config && window.config[method]) {
|
|
381
|
+
window.config[method](value);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
console.error(`${method} function is not defined in the client.`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}, calls);
|
|
388
|
+
};
|
|
389
|
+
const busyPages = [];
|
|
390
|
+
const createdInstances = [];
|
|
391
|
+
export const createInstance = async ({ key, url, useParallelPages = true, browser, requestInterceptor, useFontCache = true, } = {}) => {
|
|
392
|
+
const visitPage = url || `${DEFAULT_CLIENT}?key=${key}`;
|
|
393
|
+
const hasCustomClientUrl = Boolean(url);
|
|
394
|
+
const firstPage = useParallelPages
|
|
395
|
+
? null
|
|
396
|
+
: await createPage(browser, visitPage, requestInterceptor, useFontCache);
|
|
397
|
+
async function ensureAssetErrorHelpers(page) {
|
|
398
|
+
await page.evaluate(() => {
|
|
399
|
+
// Define helpers once per page. They unify asset-loading error handling
|
|
400
|
+
// across all exports (dataURL/image/pdf/video).
|
|
401
|
+
if (!window.__polotnoConsumeAssetError) {
|
|
402
|
+
window.__polotnoConsumeAssetError = (attrs) => {
|
|
403
|
+
const error = window._polotnoError;
|
|
404
|
+
if (!error)
|
|
405
|
+
return null;
|
|
406
|
+
// Clear immediately to avoid double-reporting.
|
|
407
|
+
window._polotnoError = null;
|
|
408
|
+
const message = String(error);
|
|
409
|
+
// Cross-package contract: every font-load error from polotno core
|
|
410
|
+
// references the family as `font "<name>"` (e.g. `Timeout for
|
|
411
|
+
// loading font "X"`, `Font "X" not found: ...`). Match that stable
|
|
412
|
+
// marker rather than any one full sentence.
|
|
413
|
+
const isFontError = /font\s+"/i.test(message);
|
|
414
|
+
const skipFontError = isFontError && attrs && attrs.skipFontError;
|
|
415
|
+
const isImageError = message.indexOf("image ") >= 0;
|
|
416
|
+
const skipImageError = isImageError && attrs && attrs.skipImageError;
|
|
417
|
+
if (skipFontError || skipImageError) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
return message;
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (!window.__polotnoThrowAssetErrorIfAny) {
|
|
424
|
+
window.__polotnoThrowAssetErrorIfAny = (attrs) => {
|
|
425
|
+
const message = window.__polotnoConsumeAssetError(attrs);
|
|
426
|
+
if (message) {
|
|
427
|
+
throw new Error(message);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
async function consumeAssetLoadingErrorMessage(page, attrs) {
|
|
434
|
+
try {
|
|
435
|
+
return await page.evaluate((attrs) => {
|
|
436
|
+
if (window.__polotnoConsumeAssetError) {
|
|
437
|
+
return window.__polotnoConsumeAssetError(attrs);
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
}, attrs);
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
// If the page is already crashed/unavailable, we can't inspect the error.
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const run = async (func, ...args) => {
|
|
448
|
+
const page = useParallelPages
|
|
449
|
+
? await createPage(browser, visitPage, requestInterceptor, useFontCache)
|
|
450
|
+
: firstPage;
|
|
451
|
+
if (busyPages.indexOf(page) >= 0) {
|
|
452
|
+
throw new Error("Current rendering context is busy with another task. Please use `useParallelPages: true` option to run multiple tasks in parallel or make sure previous task is finished before starting a new one.");
|
|
453
|
+
}
|
|
454
|
+
busyPages.push(page);
|
|
455
|
+
// Setup profiling if profilePath is provided
|
|
456
|
+
let cdpSession = null;
|
|
457
|
+
const profilePath = args[1]?.profilePath;
|
|
458
|
+
if (profilePath) {
|
|
459
|
+
cdpSession = await page.target().createCDPSession();
|
|
460
|
+
await cdpSession.send("Profiler.enable");
|
|
461
|
+
await cdpSession.send("Profiler.start");
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
// All direct `window.config.*` setters are applied from one table.
|
|
465
|
+
await applyConfigOptions(page, args[1]);
|
|
466
|
+
if (args[1]?.onProgress) {
|
|
467
|
+
const originalProgress = args[1].onProgress;
|
|
468
|
+
await page.exposeFunction("onProgress", async (progress, frameTime) => {
|
|
469
|
+
originalProgress(progress, frameTime);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
// Allow exposing custom functions (used for chunked data transfer)
|
|
473
|
+
if (args[1]?.exposeFunctions) {
|
|
474
|
+
for (const [name, fn] of Object.entries(args[1].exposeFunctions)) {
|
|
475
|
+
await page.exposeFunction(name, fn);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
await page.evaluate(() => {
|
|
479
|
+
if (window.config?.onLoadError) {
|
|
480
|
+
window.config.onLoadError((error) => {
|
|
481
|
+
window._polotnoError = error;
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
console.error("onLoadError function is not defined in the client. Error handling will not work.");
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
await ensureAssetErrorHelpers(page);
|
|
489
|
+
const result = await page.evaluate(func, ...args);
|
|
490
|
+
// Stop profiler and save profile if enabled
|
|
491
|
+
if (cdpSession && profilePath) {
|
|
492
|
+
const { profile } = await cdpSession.send("Profiler.stop");
|
|
493
|
+
fs.writeFileSync(profilePath, JSON.stringify(profile));
|
|
494
|
+
console.log(`CPU profile saved to: ${profilePath}`);
|
|
495
|
+
}
|
|
496
|
+
const assetErrorMessage = await consumeAssetLoadingErrorMessage(page, args[1]);
|
|
497
|
+
if (assetErrorMessage) {
|
|
498
|
+
throw new Error(assetErrorMessage);
|
|
499
|
+
}
|
|
500
|
+
// remove busy page
|
|
501
|
+
busyPages.splice(busyPages.indexOf(page), 1);
|
|
502
|
+
if (useParallelPages) {
|
|
503
|
+
await page.close();
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
catch (e) {
|
|
508
|
+
// Stop profiler and save profile even on error
|
|
509
|
+
if (cdpSession && profilePath) {
|
|
510
|
+
console.log("saving profile");
|
|
511
|
+
try {
|
|
512
|
+
const { profile } = await cdpSession.send("Profiler.stop");
|
|
513
|
+
fs.writeFileSync(profilePath, JSON.stringify(profile));
|
|
514
|
+
console.log(`CPU profile saved to: ${profilePath}`);
|
|
515
|
+
}
|
|
516
|
+
catch (profileError) {
|
|
517
|
+
console.error("Failed to save profile:", profileError.message);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// If `page.evaluate()` failed (e.g. thrown during onProgress), still prefer
|
|
521
|
+
// a captured Polotno asset-loading error when available.
|
|
522
|
+
const assetErrorMessage = await consumeAssetLoadingErrorMessage(page, args[1]);
|
|
523
|
+
// remove busy page
|
|
524
|
+
busyPages.splice(busyPages.indexOf(page), 1);
|
|
525
|
+
if (useParallelPages) {
|
|
526
|
+
await page.close();
|
|
527
|
+
}
|
|
528
|
+
if (assetErrorMessage) {
|
|
529
|
+
throw new Error(assetErrorMessage);
|
|
530
|
+
}
|
|
531
|
+
throw e;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
// Pre-flight schema check: catch malformed JSON before launching the browser
|
|
535
|
+
// and throw `Invalid Polotno design: …`. `assertValidDesign` mirrors the core
|
|
536
|
+
// store's tolerance (it coerces `null` to defaults like the store does, rather
|
|
537
|
+
// than rejecting input the store would happily render), so this only throws on
|
|
538
|
+
// genuinely malformed JSON.
|
|
539
|
+
const assertValidJson = (json) => {
|
|
540
|
+
assertValidDesign(json, { mode: "partial" });
|
|
541
|
+
};
|
|
542
|
+
const jsonToDataURL = async (json, attrs) => {
|
|
543
|
+
await assertValidJson(json);
|
|
544
|
+
return await run(recipeToDataURL, json, attrs || {});
|
|
545
|
+
};
|
|
546
|
+
const jsonToGIFDataURL = async (json, attrs) => {
|
|
547
|
+
await assertValidJson(json);
|
|
548
|
+
return await run(recipeToGIFDataURL, json, attrs || {});
|
|
549
|
+
};
|
|
550
|
+
const jsonToGIFBase64 = async (json, attrs) => {
|
|
551
|
+
const url = await jsonToGIFDataURL(json, attrs);
|
|
552
|
+
return url.split("base64,")[1];
|
|
553
|
+
};
|
|
554
|
+
const jsonToImageBase64 = async (json, attrs) => {
|
|
555
|
+
const url = await jsonToDataURL(json, attrs);
|
|
556
|
+
return url.split("base64,")[1];
|
|
557
|
+
};
|
|
558
|
+
const jsonToPDFDataURL = async (json, attrs) => {
|
|
559
|
+
await assertValidJson(json);
|
|
560
|
+
return await run(recipeToPDFDataURL, json, attrs || {});
|
|
561
|
+
};
|
|
562
|
+
const jsonToPDFBase64 = async (json, attrs) => {
|
|
563
|
+
const url = await jsonToPDFDataURL(json, attrs);
|
|
564
|
+
return url.split("base64,")[1];
|
|
565
|
+
};
|
|
566
|
+
const jsonToBlob = async (json, attrs) => {
|
|
567
|
+
const base64 = await jsonToImageBase64(json, attrs);
|
|
568
|
+
const blob = Buffer.from(base64, "base64");
|
|
569
|
+
return blob;
|
|
570
|
+
};
|
|
571
|
+
const jsonToVideoFile = async (json, outputPath, attrs = {}) => {
|
|
572
|
+
await assertValidJson(json);
|
|
573
|
+
const shouldDownloadMedia = !hasCustomClientUrl && !(attrs && attrs.skipDownloads);
|
|
574
|
+
const prepared = shouldDownloadMedia
|
|
575
|
+
? await prepareLocalMediaForVideoExport(json)
|
|
576
|
+
: { json, cleanup: async () => { } };
|
|
577
|
+
const writeStream = fs.createWriteStream(outputPath);
|
|
578
|
+
try {
|
|
579
|
+
const mimeType = await run(async (json, attrs) => {
|
|
580
|
+
const pixelRatio = attrs.pixelRatio || 1;
|
|
581
|
+
store.loadJSON(json);
|
|
582
|
+
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
583
|
+
// keep store internals consistent with image/pdf exports
|
|
584
|
+
store.setElementsPixelRatio(pixelRatio);
|
|
585
|
+
// loop through all pages and wait for loading for all layout calculations
|
|
586
|
+
// (this helps stabilize text/layout across multi-page designs)
|
|
587
|
+
for (const page of store.pages) {
|
|
588
|
+
store.selectPage(page.id);
|
|
589
|
+
await store.waitLoading();
|
|
590
|
+
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
591
|
+
}
|
|
592
|
+
if (store.pages.length > 0) {
|
|
593
|
+
store.selectPage(store.pages[0].id);
|
|
594
|
+
await store.waitLoading();
|
|
595
|
+
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
596
|
+
}
|
|
597
|
+
// For video export, we always use 'resize' text overflow mode
|
|
598
|
+
// to ensure text fits properly in animations. User preferences are ignored for now.
|
|
599
|
+
window.config.setTextOverflow("resize");
|
|
600
|
+
if (!window.__polotnoLoadVideoExport) {
|
|
601
|
+
throw new Error("Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().");
|
|
602
|
+
}
|
|
603
|
+
const { storeToVideo } = await window.__polotnoLoadVideoExport();
|
|
604
|
+
// Create abort controller to cancel video render on error
|
|
605
|
+
const abortController = new AbortController();
|
|
606
|
+
let capturedError = null;
|
|
607
|
+
// Always have an internal progress callback to check for errors,
|
|
608
|
+
// even if user didn't provide onProgress
|
|
609
|
+
const progressCallback = (progress, frameTime) => {
|
|
610
|
+
// Check for asset-loading errors during render (without throwing)
|
|
611
|
+
const errorMessage = window.__polotnoConsumeAssetError(attrs);
|
|
612
|
+
if (errorMessage) {
|
|
613
|
+
capturedError = errorMessage;
|
|
614
|
+
abortController.abort();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// Call user's progress callback if provided
|
|
618
|
+
if (window.onProgress) {
|
|
619
|
+
window.onProgress(progress, frameTime);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
let videoBlob;
|
|
623
|
+
try {
|
|
624
|
+
videoBlob = await storeToVideo({
|
|
625
|
+
store,
|
|
626
|
+
fps: attrs.fps,
|
|
627
|
+
pixelRatio,
|
|
628
|
+
onProgress: progressCallback,
|
|
629
|
+
signal: abortController.signal,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
catch (e) {
|
|
633
|
+
// If we aborted due to asset error, throw that instead
|
|
634
|
+
if (capturedError) {
|
|
635
|
+
throw new Error(capturedError);
|
|
636
|
+
}
|
|
637
|
+
throw e;
|
|
638
|
+
}
|
|
639
|
+
// Final check for any errors that occurred
|
|
640
|
+
if (capturedError) {
|
|
641
|
+
throw new Error(capturedError);
|
|
642
|
+
}
|
|
643
|
+
// Stream blob in chunks via exposed function to avoid large payload issues.
|
|
644
|
+
// CRITICAL (!!!): Chunk size MUST be a multiple of 3 bytes because base64 encodes
|
|
645
|
+
// 3 bytes -> 4 characters. If chunks aren't aligned, each chunk gets padding
|
|
646
|
+
// (= or ==) at the end, and joining them creates invalid base64 and video may corrupt.
|
|
647
|
+
const CHUNK_SIZE = 3 * 1024 * 1024; // 3MB, divisible by 3
|
|
648
|
+
const arrayBuffer = await videoBlob.arrayBuffer();
|
|
649
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
650
|
+
for (let offset = 0; offset < uint8Array.length; offset += CHUNK_SIZE) {
|
|
651
|
+
const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
|
|
652
|
+
const chunk = uint8Array.slice(offset, end);
|
|
653
|
+
let binary = "";
|
|
654
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
655
|
+
binary += String.fromCharCode(chunk[i]);
|
|
656
|
+
}
|
|
657
|
+
await window.__polotnoWriteChunk(btoa(binary));
|
|
658
|
+
}
|
|
659
|
+
return videoBlob.type;
|
|
660
|
+
}, prepared.json, {
|
|
661
|
+
...attrs,
|
|
662
|
+
exposeFunctions: {
|
|
663
|
+
__polotnoWriteChunk: (base64Chunk) => {
|
|
664
|
+
const buffer = Buffer.from(base64Chunk, "base64");
|
|
665
|
+
writeStream.write(buffer);
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
writeStream.end();
|
|
670
|
+
await new Promise((resolve) => writeStream.on("finish", resolve));
|
|
671
|
+
// Convert audio to AAC if needed
|
|
672
|
+
if (isFfmpegAvailable()) {
|
|
673
|
+
const codec = getAudioCodec(outputPath);
|
|
674
|
+
if (codec && codec !== "aac") {
|
|
675
|
+
const tempPath = outputPath + ".tmp.mp4";
|
|
676
|
+
convertAudioToAac(outputPath, tempPath);
|
|
677
|
+
await fs.promises.rename(tempPath, outputPath);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return mimeType;
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
await prepared.cleanup();
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
const jsonToVideoDataURL = async (json, attrs) => {
|
|
687
|
+
const tempPath = path.join(os.tmpdir(), `polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
688
|
+
try {
|
|
689
|
+
const mimeType = await jsonToVideoFile(json, tempPath, attrs);
|
|
690
|
+
const buffer = await fs.promises.readFile(tempPath);
|
|
691
|
+
return `data:${mimeType || "video/mp4"};base64,${buffer.toString("base64")}`;
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
await fs.promises.rm(tempPath, { force: true });
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
const jsonToVideoBase64 = async (json, attrs) => {
|
|
698
|
+
const tempPath = path.join(os.tmpdir(), `polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
699
|
+
try {
|
|
700
|
+
await jsonToVideoFile(json, tempPath, attrs);
|
|
701
|
+
const buffer = await fs.promises.readFile(tempPath);
|
|
702
|
+
return buffer.toString("base64");
|
|
703
|
+
}
|
|
704
|
+
finally {
|
|
705
|
+
await fs.promises.rm(tempPath, { force: true });
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
const instance = {
|
|
709
|
+
close: async () => {
|
|
710
|
+
createdInstances.splice(createdInstances.indexOf(instance), 1);
|
|
711
|
+
await browser.close();
|
|
712
|
+
},
|
|
713
|
+
firstPage,
|
|
714
|
+
browser,
|
|
715
|
+
run,
|
|
716
|
+
jsonToDataURL,
|
|
717
|
+
jsonToImageBase64,
|
|
718
|
+
jsonToPDFDataURL,
|
|
719
|
+
jsonToPDFBase64,
|
|
720
|
+
jsonToBlob,
|
|
721
|
+
jsonToGIFDataURL,
|
|
722
|
+
jsonToGIFBase64,
|
|
723
|
+
jsonToVideoDataURL,
|
|
724
|
+
jsonToVideoBase64,
|
|
725
|
+
jsonToVideoFile,
|
|
726
|
+
createPage: async () => await createPage(browser, visitPage, requestInterceptor, useFontCache),
|
|
727
|
+
};
|
|
728
|
+
createdInstances.push(instance);
|
|
729
|
+
return instance;
|
|
730
|
+
};
|
|
731
|
+
// Function to close all created instances in parallel
|
|
732
|
+
const closeAllInstances = async () => {
|
|
733
|
+
await Promise.all(createdInstances.map((instance) => instance.close()));
|
|
734
|
+
};
|
|
735
|
+
// Also handle other termination signals
|
|
736
|
+
["SIGINT", "SIGTERM", "SIGQUIT", "exit"].forEach((signal) => {
|
|
737
|
+
process.on(signal, () => {
|
|
738
|
+
closeAllInstances();
|
|
739
|
+
});
|
|
740
|
+
});
|