polotno-node 3.1.3 → 3.2.0
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 +4 -4
- package/dist/assets/index-j-YZMZrC.js +668 -0
- package/dist/assets/{jspdf.es.min-BIRHrXiC.js → jspdf.es.min-EyQhoXfa.js} +1 -1
- package/dist/assets/{lib-B8RASP1f.js → lib-BguUsusU.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/errors.d.ts +4 -4
- package/lib/errors.js +2 -2
- package/lib/font-cache.js +12 -12
- package/lib/instance.d.ts +3 -2
- package/lib/instance.js +140 -118
- package/lib/types.d.ts +7 -0
- package/package.json +7 -7
- package/dist/assets/index-BtUscQGT.js +0 -669
package/lib/instance.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import path from
|
|
2
|
-
import fs from
|
|
3
|
-
import os from
|
|
4
|
-
import crypto from
|
|
5
|
-
import { execSync } from
|
|
6
|
-
import { pathToFileURL, fileURLToPath } from
|
|
7
|
-
import { Readable } from
|
|
8
|
-
import { pipeline } from
|
|
9
|
-
import { assertValidDesign } from
|
|
10
|
-
import * as fontCache from
|
|
11
|
-
import { polotnoError, rehydrateError } from
|
|
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
|
+
import { polotnoError, rehydrateError } from './errors.js';
|
|
12
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
// Compiled to lib/instance.js; the client bundle is published to ../dist.
|
|
14
|
-
const DEFAULT_CLIENT = `file:${path.join(__dirname,
|
|
14
|
+
const DEFAULT_CLIENT = `file:${path.join(__dirname, '..', 'dist', 'index.html')}`;
|
|
15
15
|
// Walk Polotno nodes recursively (depth-first)
|
|
16
16
|
// cb(node) may return true to stop traversing the current branch early
|
|
17
17
|
const forEveryChild = (node, cb) => {
|
|
@@ -32,61 +32,61 @@ const collectMediaSourcePointers = (json) => {
|
|
|
32
32
|
// videos in elements tree
|
|
33
33
|
for (const page of json?.pages || []) {
|
|
34
34
|
for (const el of page?.children || []) {
|
|
35
|
-
if (el?.type ===
|
|
36
|
-
pointers.push({ kind:
|
|
35
|
+
if (el?.type === 'video' && typeof el.src === 'string') {
|
|
36
|
+
pointers.push({ kind: 'video', target: el, key: 'src' });
|
|
37
37
|
}
|
|
38
38
|
forEveryChild(el, (child) => {
|
|
39
|
-
if (child?.type ===
|
|
40
|
-
pointers.push({ kind:
|
|
39
|
+
if (child?.type === 'video' && typeof child.src === 'string') {
|
|
40
|
+
pointers.push({ kind: 'video', target: child, key: 'src' });
|
|
41
41
|
}
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
// audios array
|
|
46
46
|
for (const audio of json?.audios || []) {
|
|
47
|
-
if (audio && typeof audio.src ===
|
|
48
|
-
pointers.push({ kind:
|
|
47
|
+
if (audio && typeof audio.src === 'string') {
|
|
48
|
+
pointers.push({ kind: 'audio', target: audio, key: 'src' });
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
return pointers;
|
|
52
52
|
};
|
|
53
53
|
const isDataOrFileSrc = (src) => {
|
|
54
|
-
if (typeof src !==
|
|
54
|
+
if (typeof src !== 'string')
|
|
55
55
|
return true;
|
|
56
|
-
return (src.startsWith(
|
|
57
|
-
src.startsWith(
|
|
58
|
-
src.startsWith(
|
|
56
|
+
return (src.startsWith('data:') ||
|
|
57
|
+
src.startsWith('file:') ||
|
|
58
|
+
src.startsWith('blob:'));
|
|
59
59
|
};
|
|
60
60
|
const guessExtension = (urlString, contentType) => {
|
|
61
61
|
try {
|
|
62
62
|
const u = new URL(urlString);
|
|
63
|
-
const ext = path.extname(u.pathname ||
|
|
63
|
+
const ext = path.extname(u.pathname || '');
|
|
64
64
|
if (ext)
|
|
65
65
|
return ext;
|
|
66
66
|
}
|
|
67
67
|
catch (e) {
|
|
68
68
|
// ignore; fallback to content-type
|
|
69
69
|
}
|
|
70
|
-
const ct = String(contentType ||
|
|
71
|
-
.split(
|
|
70
|
+
const ct = String(contentType || '')
|
|
71
|
+
.split(';')[0]
|
|
72
72
|
.trim()
|
|
73
73
|
.toLowerCase();
|
|
74
74
|
const map = {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
'video/mp4': '.mp4',
|
|
76
|
+
'video/webm': '.webm',
|
|
77
|
+
'video/quicktime': '.mov',
|
|
78
|
+
'audio/mpeg': '.mp3',
|
|
79
|
+
'audio/mp4': '.m4a',
|
|
80
|
+
'audio/wav': '.wav',
|
|
81
|
+
'audio/x-wav': '.wav',
|
|
82
|
+
'audio/ogg': '.ogg',
|
|
83
|
+
'audio/webm': '.webm',
|
|
84
84
|
};
|
|
85
|
-
return map[ct] ||
|
|
85
|
+
return map[ct] || '.bin';
|
|
86
86
|
};
|
|
87
87
|
const downloadUrlToFile = async (urlString, destinationPath) => {
|
|
88
|
-
if (typeof fetch !==
|
|
89
|
-
throw polotnoError(
|
|
88
|
+
if (typeof fetch !== 'function') {
|
|
89
|
+
throw polotnoError('FETCH_FAILED', 'Global fetch API is not available in this Node.js runtime. Please use Node 18+.', { url: urlString });
|
|
90
90
|
}
|
|
91
91
|
const MAX_ATTEMPTS = 3;
|
|
92
92
|
const BASE_DELAY_MS = 300;
|
|
@@ -98,11 +98,11 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
|
|
|
98
98
|
const res = await fetch(urlString);
|
|
99
99
|
if (!res.ok) {
|
|
100
100
|
// Best-effort read of error body (small) for debugging.
|
|
101
|
-
let bodyPreview =
|
|
101
|
+
let bodyPreview = '';
|
|
102
102
|
try {
|
|
103
103
|
bodyPreview = await res.text();
|
|
104
104
|
if (bodyPreview.length > 500) {
|
|
105
|
-
bodyPreview = bodyPreview.slice(0, 500) +
|
|
105
|
+
bodyPreview = bodyPreview.slice(0, 500) + '...';
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
catch (e) {
|
|
@@ -113,16 +113,16 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
|
|
|
113
113
|
bodyPreview ? `body: ${JSON.stringify(bodyPreview)}` : null,
|
|
114
114
|
]
|
|
115
115
|
.filter(Boolean)
|
|
116
|
-
.join(
|
|
117
|
-
throw polotnoError(
|
|
116
|
+
.join(', ');
|
|
117
|
+
throw polotnoError('FETCH_FAILED', `HTTP error while downloading: ${details}`, { url: urlString, status: res.status });
|
|
118
118
|
}
|
|
119
119
|
if (!res.body) {
|
|
120
|
-
throw polotnoError(
|
|
120
|
+
throw polotnoError('FETCH_FAILED', 'Empty response body', {
|
|
121
121
|
url: urlString,
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
124
|
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destinationPath));
|
|
125
|
-
return res.headers.get(
|
|
125
|
+
return res.headers.get('content-type') || '';
|
|
126
126
|
}
|
|
127
127
|
catch (e) {
|
|
128
128
|
lastError = e;
|
|
@@ -134,24 +134,24 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
const message = lastError && lastError.message ? lastError.message : String(lastError);
|
|
137
|
-
throw polotnoError(
|
|
137
|
+
throw polotnoError('FETCH_FAILED', `Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`, { url: urlString, attempts: MAX_ATTEMPTS }, lastError);
|
|
138
138
|
};
|
|
139
139
|
// Returns a human-readable reason string if the video file needs transcoding
|
|
140
140
|
// for Chromium headless compatibility, or null if it is already render-safe.
|
|
141
141
|
const getVideoNormalizationReason = (filePath) => {
|
|
142
142
|
try {
|
|
143
|
-
const raw = execSync(`ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,codec_tag_string,pix_fmt -of json "${filePath}"`, { encoding:
|
|
143
|
+
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' });
|
|
144
144
|
const info = JSON.parse(raw);
|
|
145
145
|
const vs = info?.streams?.[0];
|
|
146
146
|
if (!vs)
|
|
147
147
|
return null;
|
|
148
|
-
const codec = (vs.codec_name ||
|
|
149
|
-
const tag = (vs.codec_tag_string ||
|
|
150
|
-
if ([
|
|
151
|
-
return
|
|
148
|
+
const codec = (vs.codec_name || '').toLowerCase();
|
|
149
|
+
const tag = (vs.codec_tag_string || '').toLowerCase();
|
|
150
|
+
if (['hevc', 'h265'].includes(codec) || ['hvc1', 'hev1'].includes(tag)) {
|
|
151
|
+
return 'HEVC/H.265 is not supported by Chromium headless';
|
|
152
152
|
}
|
|
153
|
-
const pix = (vs.pix_fmt ||
|
|
154
|
-
if (pix && pix !==
|
|
153
|
+
const pix = (vs.pix_fmt || '').toLowerCase();
|
|
154
|
+
if (pix && pix !== 'yuv420p') {
|
|
155
155
|
return `pixel format "${pix}" is not the preferred yuv420p`;
|
|
156
156
|
}
|
|
157
157
|
return null;
|
|
@@ -162,7 +162,7 @@ const getVideoNormalizationReason = (filePath) => {
|
|
|
162
162
|
};
|
|
163
163
|
const isFfmpegAvailable = () => {
|
|
164
164
|
try {
|
|
165
|
-
execSync(
|
|
165
|
+
execSync('ffmpeg -version', { stdio: 'ignore' });
|
|
166
166
|
return true;
|
|
167
167
|
}
|
|
168
168
|
catch {
|
|
@@ -171,7 +171,7 @@ const isFfmpegAvailable = () => {
|
|
|
171
171
|
};
|
|
172
172
|
const getAudioCodec = (filePath) => {
|
|
173
173
|
try {
|
|
174
|
-
const result = execSync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`, { encoding:
|
|
174
|
+
const result = execSync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`, { encoding: 'utf8' });
|
|
175
175
|
return result.trim();
|
|
176
176
|
}
|
|
177
177
|
catch {
|
|
@@ -180,7 +180,7 @@ const getAudioCodec = (filePath) => {
|
|
|
180
180
|
};
|
|
181
181
|
const convertAudioToAac = (inputPath, outputPath) => {
|
|
182
182
|
execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
|
|
183
|
-
stdio:
|
|
183
|
+
stdio: 'ignore',
|
|
184
184
|
});
|
|
185
185
|
};
|
|
186
186
|
const prepareLocalMediaForVideoExport = async (json) => {
|
|
@@ -195,7 +195,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
|
|
|
195
195
|
continue;
|
|
196
196
|
try {
|
|
197
197
|
const u = new URL(src);
|
|
198
|
-
if (u.protocol !==
|
|
198
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
199
199
|
continue;
|
|
200
200
|
}
|
|
201
201
|
catch (e) {
|
|
@@ -205,13 +205,13 @@ const prepareLocalMediaForVideoExport = async (json) => {
|
|
|
205
205
|
rewriteMap.set(src, null);
|
|
206
206
|
urlsToDownload.push(src);
|
|
207
207
|
}
|
|
208
|
-
if (p.kind ===
|
|
208
|
+
if (p.kind === 'video')
|
|
209
209
|
videoUrls.add(src);
|
|
210
210
|
}
|
|
211
211
|
if (urlsToDownload.length === 0) {
|
|
212
212
|
return { json: clonedJson, cleanup: async () => { } };
|
|
213
213
|
}
|
|
214
|
-
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(),
|
|
214
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'polotno-node-assets-'));
|
|
215
215
|
let didCleanup = false;
|
|
216
216
|
const cleanup = async () => {
|
|
217
217
|
if (didCleanup)
|
|
@@ -223,7 +223,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
|
|
|
223
223
|
await Promise.all(urlsToDownload.map(async (src) => {
|
|
224
224
|
const tmpBase = crypto.randomUUID
|
|
225
225
|
? crypto.randomUUID()
|
|
226
|
-
: crypto.randomBytes(16).toString(
|
|
226
|
+
: crypto.randomBytes(16).toString('hex');
|
|
227
227
|
const tmpPath = path.join(tempDir, tmpBase);
|
|
228
228
|
const contentType = await downloadUrlToFile(src, tmpPath);
|
|
229
229
|
const ext = guessExtension(src, contentType);
|
|
@@ -232,8 +232,8 @@ const prepareLocalMediaForVideoExport = async (json) => {
|
|
|
232
232
|
if (videoUrls.has(src)) {
|
|
233
233
|
const reason = getVideoNormalizationReason(finalPath);
|
|
234
234
|
if (reason) {
|
|
235
|
-
const normalizedPath = finalPath +
|
|
236
|
-
execSync(`ffmpeg -y -i "${finalPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${normalizedPath}"`, { stdio:
|
|
235
|
+
const normalizedPath = finalPath + '.normalized.mp4';
|
|
236
|
+
execSync(`ffmpeg -y -i "${finalPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${normalizedPath}"`, { stdio: 'ignore' });
|
|
237
237
|
finalPath = normalizedPath;
|
|
238
238
|
}
|
|
239
239
|
}
|
|
@@ -253,16 +253,23 @@ const prepareLocalMediaForVideoExport = async (json) => {
|
|
|
253
253
|
throw e;
|
|
254
254
|
}
|
|
255
255
|
};
|
|
256
|
-
|
|
256
|
+
// `page.goto` below used to inherit the browser driver's 30s default. That is
|
|
257
|
+
// too tight for this navigation — it loads the whole polotno client bundle, and
|
|
258
|
+
// on a slow or loaded machine createInstance would throw `Navigation timeout of
|
|
259
|
+
// 30000 ms exceeded` with no option to tune: `protocolTimeout` governs CDP
|
|
260
|
+
// calls, not the navigation lifecycle. 2 minutes is generous but still bounded,
|
|
261
|
+
// unlike `timeout: 0` ("disabled"), which turns a wedged load into a hang.
|
|
262
|
+
const DEFAULT_NAVIGATION_TIMEOUT = 120 * 1000;
|
|
263
|
+
export const createPage = async (browser, url, requestInterceptor, useFontCache = true, navigationTimeout = DEFAULT_NAVIGATION_TIMEOUT) => {
|
|
257
264
|
const page = await browser.newPage();
|
|
258
265
|
// Attach request interceptor if provided
|
|
259
|
-
if (useFontCache || typeof requestInterceptor ===
|
|
266
|
+
if (useFontCache || typeof requestInterceptor === 'function') {
|
|
260
267
|
await page.setRequestInterception(true);
|
|
261
|
-
page.on(
|
|
268
|
+
page.on('request', (request) => {
|
|
262
269
|
if (useFontCache && fontCache.tryRespond(request)) {
|
|
263
270
|
return;
|
|
264
271
|
}
|
|
265
|
-
if (typeof requestInterceptor ===
|
|
272
|
+
if (typeof requestInterceptor === 'function') {
|
|
266
273
|
requestInterceptor(request);
|
|
267
274
|
}
|
|
268
275
|
else {
|
|
@@ -270,7 +277,7 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
|
|
|
270
277
|
}
|
|
271
278
|
});
|
|
272
279
|
if (useFontCache) {
|
|
273
|
-
page.on(
|
|
280
|
+
page.on('response', (response) => fontCache.capture(response));
|
|
274
281
|
}
|
|
275
282
|
}
|
|
276
283
|
// we need to make sure that headless is inside user agent
|
|
@@ -278,31 +285,31 @@ export const createPage = async (browser, url, requestInterceptor, useFontCache
|
|
|
278
285
|
// but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
|
|
279
286
|
// so some languages may not work
|
|
280
287
|
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
281
|
-
if (userAgent.indexOf(
|
|
282
|
-
await page.setUserAgent(userAgent +
|
|
288
|
+
if (userAgent.indexOf('Headless') === -1) {
|
|
289
|
+
await page.setUserAgent(userAgent + ' Chrome Headless');
|
|
283
290
|
}
|
|
284
|
-
page.on(
|
|
291
|
+
page.on('console', (msg) => {
|
|
285
292
|
msg.args().forEach(async (message) => {
|
|
286
293
|
// skip style information
|
|
287
|
-
if (message.toString().indexOf(
|
|
294
|
+
if (message.toString().indexOf('margin') >= 0) {
|
|
288
295
|
return;
|
|
289
296
|
}
|
|
290
297
|
const { subtype, description } = message.remoteObject();
|
|
291
|
-
if (subtype ===
|
|
298
|
+
if (subtype === 'error') {
|
|
292
299
|
console.error(description);
|
|
293
300
|
return;
|
|
294
301
|
}
|
|
295
302
|
const text = message
|
|
296
303
|
.toString()
|
|
297
|
-
.replace(
|
|
298
|
-
.replace(
|
|
304
|
+
.replace('JSHandle:', '')
|
|
305
|
+
.replace('%c', '');
|
|
299
306
|
console.log(text);
|
|
300
307
|
});
|
|
301
308
|
});
|
|
302
|
-
page.on(
|
|
309
|
+
page.on('pageerror', (msg) => {
|
|
303
310
|
console.error(msg);
|
|
304
311
|
});
|
|
305
|
-
await page.goto(url);
|
|
312
|
+
await page.goto(url, { timeout: navigationTimeout });
|
|
306
313
|
return page;
|
|
307
314
|
};
|
|
308
315
|
export const run = async (page, func, args) => {
|
|
@@ -355,18 +362,30 @@ export const jsonToBlob = async (page, json, attrs) => {
|
|
|
355
362
|
// is an enable-only flag: the setter is called with `true` only when the option
|
|
356
363
|
// is truthy (passing `false` is a no-op, leaving the client default in place).
|
|
357
364
|
const CONFIG_OPTION_SETTERS = [
|
|
358
|
-
{ option:
|
|
359
|
-
{ option:
|
|
360
|
-
{
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
{ option: 'assetLoadTimeout', method: 'setAssetLoadTimeout', arg: 'value' },
|
|
366
|
+
{ option: 'fontLoadTimeout', method: 'setFontLoadTimeout', arg: 'value' },
|
|
367
|
+
{
|
|
368
|
+
option: 'legacyRichTextEnabled',
|
|
369
|
+
method: 'setLegacyRichTextEnabled',
|
|
370
|
+
arg: true,
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
option: 'textVerticalResizeEnabled',
|
|
374
|
+
method: 'setTextVerticalResizeEnabled',
|
|
375
|
+
arg: 'value',
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
option: 'textSplitAllowed',
|
|
379
|
+
method: 'unstable_setTextSplitAllowed',
|
|
380
|
+
arg: true,
|
|
381
|
+
},
|
|
382
|
+
{ option: 'textOverflow', method: 'setTextOverflow', arg: 'value' },
|
|
364
383
|
];
|
|
365
384
|
// Apply the option→config setters above to a page in a single round-trip.
|
|
366
385
|
const applyConfigOptions = async (page, attrs = {}) => {
|
|
367
386
|
const calls = [];
|
|
368
387
|
for (const { option, method, arg } of CONFIG_OPTION_SETTERS) {
|
|
369
|
-
if (arg ===
|
|
388
|
+
if (arg === 'value') {
|
|
370
389
|
// value passthrough — apply on any explicit value, including `false`
|
|
371
390
|
if (attrs[option] !== undefined)
|
|
372
391
|
calls.push({ method, value: attrs[option] });
|
|
@@ -391,12 +410,13 @@ const applyConfigOptions = async (page, attrs = {}) => {
|
|
|
391
410
|
};
|
|
392
411
|
const busyPages = [];
|
|
393
412
|
const createdInstances = [];
|
|
394
|
-
export const createInstance = async ({ key, url, useParallelPages = true, browser, requestInterceptor, useFontCache = true, } = {}) => {
|
|
413
|
+
export const createInstance = async ({ key, url, useParallelPages = true, browser, requestInterceptor, useFontCache = true, navigationTimeout, } = {}) => {
|
|
395
414
|
const visitPage = url || `${DEFAULT_CLIENT}?key=${key}`;
|
|
396
415
|
const hasCustomClientUrl = Boolean(url);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
416
|
+
// Every page this instance opens is configured identically; bind it once so
|
|
417
|
+
// the three call sites below cannot drift apart.
|
|
418
|
+
const openClientPage = () => createPage(browser, visitPage, requestInterceptor, useFontCache, navigationTimeout);
|
|
419
|
+
const firstPage = useParallelPages ? null : await openClientPage();
|
|
400
420
|
async function ensureAssetErrorHelpers(page) {
|
|
401
421
|
await page.evaluate(() => {
|
|
402
422
|
// Define helpers once per page. They unify asset-loading error handling
|
|
@@ -411,11 +431,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
411
431
|
// The client stashes a structured `{code, message, details}`
|
|
412
432
|
// payload (image timeouts arrive as IMAGE_FAILED/reason:'timeout',
|
|
413
433
|
// so skip flags cover them too).
|
|
414
|
-
const code = typeof payload ===
|
|
415
|
-
if (code ===
|
|
434
|
+
const code = typeof payload === 'object' ? payload.code : null;
|
|
435
|
+
if (code === 'FONT_FAILED' && attrs && attrs.skipFontError) {
|
|
416
436
|
return null;
|
|
417
437
|
}
|
|
418
|
-
if (code ===
|
|
438
|
+
if (code === 'IMAGE_FAILED' && attrs && attrs.skipImageError) {
|
|
419
439
|
return null;
|
|
420
440
|
}
|
|
421
441
|
// Return the payload as-is (object, or string from a legacy client);
|
|
@@ -431,7 +451,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
431
451
|
// this throw is flattened by page.evaluate (thrown Errors cross
|
|
432
452
|
// the boundary as message-only).
|
|
433
453
|
window._polotnoError = payload;
|
|
434
|
-
throw new Error(typeof payload ===
|
|
454
|
+
throw new Error(typeof payload === 'string' ? payload : String(payload.message));
|
|
435
455
|
}
|
|
436
456
|
};
|
|
437
457
|
}
|
|
@@ -452,11 +472,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
452
472
|
}
|
|
453
473
|
}
|
|
454
474
|
const run = async (func, ...args) => {
|
|
455
|
-
const page = useParallelPages
|
|
456
|
-
? await createPage(browser, visitPage, requestInterceptor, useFontCache)
|
|
457
|
-
: firstPage;
|
|
475
|
+
const page = useParallelPages ? await openClientPage() : firstPage;
|
|
458
476
|
if (busyPages.indexOf(page) >= 0) {
|
|
459
|
-
throw polotnoError(
|
|
477
|
+
throw polotnoError('EXPORT_FAILED', '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.', { reason: 'busy' });
|
|
460
478
|
}
|
|
461
479
|
busyPages.push(page);
|
|
462
480
|
// Setup profiling if profilePath is provided
|
|
@@ -464,15 +482,15 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
464
482
|
const profilePath = args[1]?.profilePath;
|
|
465
483
|
if (profilePath) {
|
|
466
484
|
cdpSession = await page.target().createCDPSession();
|
|
467
|
-
await cdpSession.send(
|
|
468
|
-
await cdpSession.send(
|
|
485
|
+
await cdpSession.send('Profiler.enable');
|
|
486
|
+
await cdpSession.send('Profiler.start');
|
|
469
487
|
}
|
|
470
488
|
try {
|
|
471
489
|
// All direct `window.config.*` setters are applied from one table.
|
|
472
490
|
await applyConfigOptions(page, args[1]);
|
|
473
491
|
if (args[1]?.onProgress) {
|
|
474
492
|
const originalProgress = args[1].onProgress;
|
|
475
|
-
await page.exposeFunction(
|
|
493
|
+
await page.exposeFunction('onProgress', async (progress, frameTime) => {
|
|
476
494
|
originalProgress(progress, frameTime);
|
|
477
495
|
});
|
|
478
496
|
}
|
|
@@ -490,19 +508,23 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
490
508
|
const e = error;
|
|
491
509
|
window._polotnoError =
|
|
492
510
|
e && e.code
|
|
493
|
-
? {
|
|
511
|
+
? {
|
|
512
|
+
code: e.code,
|
|
513
|
+
message: String(e.message),
|
|
514
|
+
details: e.details,
|
|
515
|
+
}
|
|
494
516
|
: String(error);
|
|
495
517
|
});
|
|
496
518
|
}
|
|
497
519
|
else {
|
|
498
|
-
console.error(
|
|
520
|
+
console.error('onLoadError function is not defined in the client. Error handling will not work.');
|
|
499
521
|
}
|
|
500
522
|
});
|
|
501
523
|
await ensureAssetErrorHelpers(page);
|
|
502
524
|
const result = await page.evaluate(func, ...args);
|
|
503
525
|
// Stop profiler and save profile if enabled
|
|
504
526
|
if (cdpSession && profilePath) {
|
|
505
|
-
const { profile } = await cdpSession.send(
|
|
527
|
+
const { profile } = await cdpSession.send('Profiler.stop');
|
|
506
528
|
fs.writeFileSync(profilePath, JSON.stringify(profile));
|
|
507
529
|
console.log(`CPU profile saved to: ${profilePath}`);
|
|
508
530
|
}
|
|
@@ -520,14 +542,14 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
520
542
|
catch (e) {
|
|
521
543
|
// Stop profiler and save profile even on error
|
|
522
544
|
if (cdpSession && profilePath) {
|
|
523
|
-
console.log(
|
|
545
|
+
console.log('saving profile');
|
|
524
546
|
try {
|
|
525
|
-
const { profile } = await cdpSession.send(
|
|
547
|
+
const { profile } = await cdpSession.send('Profiler.stop');
|
|
526
548
|
fs.writeFileSync(profilePath, JSON.stringify(profile));
|
|
527
549
|
console.log(`CPU profile saved to: ${profilePath}`);
|
|
528
550
|
}
|
|
529
551
|
catch (profileError) {
|
|
530
|
-
console.error(
|
|
552
|
+
console.error('Failed to save profile:', profileError.message);
|
|
531
553
|
}
|
|
532
554
|
}
|
|
533
555
|
// If `page.evaluate()` failed (e.g. thrown during onProgress), still prefer
|
|
@@ -550,7 +572,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
550
572
|
// than rejecting input the store would happily render), so this only throws on
|
|
551
573
|
// genuinely malformed JSON.
|
|
552
574
|
const assertValidJson = (json) => {
|
|
553
|
-
assertValidDesign(json, { mode:
|
|
575
|
+
assertValidDesign(json, { mode: 'partial' });
|
|
554
576
|
};
|
|
555
577
|
const jsonToDataURL = async (json, attrs) => {
|
|
556
578
|
await assertValidJson(json);
|
|
@@ -562,11 +584,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
562
584
|
};
|
|
563
585
|
const jsonToGIFBase64 = async (json, attrs) => {
|
|
564
586
|
const url = await jsonToGIFDataURL(json, attrs);
|
|
565
|
-
return url.split(
|
|
587
|
+
return url.split('base64,')[1];
|
|
566
588
|
};
|
|
567
589
|
const jsonToImageBase64 = async (json, attrs) => {
|
|
568
590
|
const url = await jsonToDataURL(json, attrs);
|
|
569
|
-
return url.split(
|
|
591
|
+
return url.split('base64,')[1];
|
|
570
592
|
};
|
|
571
593
|
const jsonToPDFDataURL = async (json, attrs) => {
|
|
572
594
|
await assertValidJson(json);
|
|
@@ -574,11 +596,11 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
574
596
|
};
|
|
575
597
|
const jsonToPDFBase64 = async (json, attrs) => {
|
|
576
598
|
const url = await jsonToPDFDataURL(json, attrs);
|
|
577
|
-
return url.split(
|
|
599
|
+
return url.split('base64,')[1];
|
|
578
600
|
};
|
|
579
601
|
const jsonToBlob = async (json, attrs) => {
|
|
580
602
|
const base64 = await jsonToImageBase64(json, attrs);
|
|
581
|
-
const blob = Buffer.from(base64,
|
|
603
|
+
const blob = Buffer.from(base64, 'base64');
|
|
582
604
|
return blob;
|
|
583
605
|
};
|
|
584
606
|
const jsonToVideoFile = async (json, outputPath, attrs = {}) => {
|
|
@@ -609,9 +631,9 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
609
631
|
}
|
|
610
632
|
// For video export, we always use 'resize' text overflow mode
|
|
611
633
|
// to ensure text fits properly in animations. User preferences are ignored for now.
|
|
612
|
-
window.config.setTextOverflow(
|
|
634
|
+
window.config.setTextOverflow('resize');
|
|
613
635
|
if (!window.__polotnoLoadVideoExport) {
|
|
614
|
-
throw new Error(
|
|
636
|
+
throw new Error('Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().');
|
|
615
637
|
}
|
|
616
638
|
const { storeToVideo } = await window.__polotnoLoadVideoExport();
|
|
617
639
|
// Create abort controller to cancel video render on error
|
|
@@ -667,7 +689,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
667
689
|
for (let offset = 0; offset < uint8Array.length; offset += CHUNK_SIZE) {
|
|
668
690
|
const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
|
|
669
691
|
const chunk = uint8Array.slice(offset, end);
|
|
670
|
-
let binary =
|
|
692
|
+
let binary = '';
|
|
671
693
|
for (let i = 0; i < chunk.length; i++) {
|
|
672
694
|
binary += String.fromCharCode(chunk[i]);
|
|
673
695
|
}
|
|
@@ -678,18 +700,18 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
678
700
|
...attrs,
|
|
679
701
|
exposeFunctions: {
|
|
680
702
|
__polotnoWriteChunk: (base64Chunk) => {
|
|
681
|
-
const buffer = Buffer.from(base64Chunk,
|
|
703
|
+
const buffer = Buffer.from(base64Chunk, 'base64');
|
|
682
704
|
writeStream.write(buffer);
|
|
683
705
|
},
|
|
684
706
|
},
|
|
685
707
|
});
|
|
686
708
|
writeStream.end();
|
|
687
|
-
await new Promise((resolve) => writeStream.on(
|
|
709
|
+
await new Promise((resolve) => writeStream.on('finish', resolve));
|
|
688
710
|
// Convert audio to AAC if needed
|
|
689
711
|
if (isFfmpegAvailable()) {
|
|
690
712
|
const codec = getAudioCodec(outputPath);
|
|
691
|
-
if (codec && codec !==
|
|
692
|
-
const tempPath = outputPath +
|
|
713
|
+
if (codec && codec !== 'aac') {
|
|
714
|
+
const tempPath = outputPath + '.tmp.mp4';
|
|
693
715
|
convertAudioToAac(outputPath, tempPath);
|
|
694
716
|
await fs.promises.rename(tempPath, outputPath);
|
|
695
717
|
}
|
|
@@ -705,7 +727,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
705
727
|
try {
|
|
706
728
|
const mimeType = await jsonToVideoFile(json, tempPath, attrs);
|
|
707
729
|
const buffer = await fs.promises.readFile(tempPath);
|
|
708
|
-
return `data:${mimeType ||
|
|
730
|
+
return `data:${mimeType || 'video/mp4'};base64,${buffer.toString('base64')}`;
|
|
709
731
|
}
|
|
710
732
|
finally {
|
|
711
733
|
await fs.promises.rm(tempPath, { force: true });
|
|
@@ -716,7 +738,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
716
738
|
try {
|
|
717
739
|
await jsonToVideoFile(json, tempPath, attrs);
|
|
718
740
|
const buffer = await fs.promises.readFile(tempPath);
|
|
719
|
-
return buffer.toString(
|
|
741
|
+
return buffer.toString('base64');
|
|
720
742
|
}
|
|
721
743
|
finally {
|
|
722
744
|
await fs.promises.rm(tempPath, { force: true });
|
|
@@ -740,7 +762,7 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
740
762
|
jsonToVideoDataURL,
|
|
741
763
|
jsonToVideoBase64,
|
|
742
764
|
jsonToVideoFile,
|
|
743
|
-
createPage:
|
|
765
|
+
createPage: openClientPage,
|
|
744
766
|
};
|
|
745
767
|
createdInstances.push(instance);
|
|
746
768
|
return instance;
|
|
@@ -750,7 +772,7 @@ const closeAllInstances = async () => {
|
|
|
750
772
|
await Promise.all(createdInstances.map((instance) => instance.close()));
|
|
751
773
|
};
|
|
752
774
|
// Also handle other termination signals
|
|
753
|
-
[
|
|
775
|
+
['SIGINT', 'SIGTERM', 'SIGQUIT', 'exit'].forEach((signal) => {
|
|
754
776
|
process.on(signal, () => {
|
|
755
777
|
closeAllInstances();
|
|
756
778
|
});
|
package/lib/types.d.ts
CHANGED
|
@@ -56,6 +56,13 @@ export interface InstanceOptions {
|
|
|
56
56
|
requestInterceptor?: (request: any) => void;
|
|
57
57
|
/** Cache Google Fonts responses for the process lifetime and serve them to every page (default: true) */
|
|
58
58
|
useFontCache?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Budget for the initial navigation to the client page, in ms (default:
|
|
61
|
+
* 120000). Distinct from `protocolTimeout`, which governs CDP calls and not
|
|
62
|
+
* the navigation lifecycle. Raise it on slow machines; `0` disables it, at
|
|
63
|
+
* the cost of turning a wedged load into a permanent hang.
|
|
64
|
+
*/
|
|
65
|
+
navigationTimeout?: number;
|
|
59
66
|
}
|
|
60
67
|
export interface PolotnoInstance {
|
|
61
68
|
close: () => Promise<void>;
|