polotno-node 2.15.15 → 2.15.16
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 +6 -0
- package/instance.js +107 -68
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -251,8 +251,12 @@ By default, when exporting video and **only** when using the default local clien
|
|
|
251
251
|
|
|
252
252
|
- Download all **remote** video elements (`type: 'video'`) and `json.audios` sources into a temporary folder
|
|
253
253
|
- Deduplicate downloads (same URL is downloaded once)
|
|
254
|
+
- Probe downloaded remote videos with `ffprobe`
|
|
255
|
+
- Conditionally normalize incompatible downloaded videos into MP4 with H.264 video, `yuv420p`, and AAC audio for better Chromium render compatibility
|
|
254
256
|
- Rewrite `src` to `file://...` URLs so Chromium loads media from disk
|
|
255
257
|
|
|
258
|
+
For best compatibility, use MP4 with H.264 (`yuv420p`) video and AAC audio.
|
|
259
|
+
|
|
256
260
|
If you want to disable this behavior, set:
|
|
257
261
|
|
|
258
262
|
```js
|
|
@@ -261,6 +265,8 @@ const url = await instance.jsonToVideoDataURL(json, {
|
|
|
261
265
|
});
|
|
262
266
|
```
|
|
263
267
|
|
|
268
|
+
When `skipDownloads` is enabled, `polotno-node` will skip both remote-media downloading and the conditional video preprocessing described above.
|
|
269
|
+
|
|
264
270
|
### `attrs.assetLoadTimeout`
|
|
265
271
|
|
|
266
272
|
You can add `assetLoadTimeout` attribute to `attrs` object. It will be used to set timeout for loading assets. By default it is 30000ms.
|
package/instance.js
CHANGED
|
@@ -89,7 +89,7 @@ const guessExtension = (urlString, contentType) => {
|
|
|
89
89
|
const downloadUrlToFile = async (urlString, destinationPath) => {
|
|
90
90
|
if (typeof fetch !== 'function') {
|
|
91
91
|
throw new Error(
|
|
92
|
-
'Global fetch API is not available in this Node.js runtime. Please use Node 18+.'
|
|
92
|
+
'Global fetch API is not available in this Node.js runtime. Please use Node 18+.',
|
|
93
93
|
);
|
|
94
94
|
}
|
|
95
95
|
const MAX_ATTEMPTS = 3;
|
|
@@ -128,7 +128,7 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
|
|
|
128
128
|
|
|
129
129
|
await pipeline(
|
|
130
130
|
Readable.fromWeb(res.body),
|
|
131
|
-
fs.createWriteStream(destinationPath)
|
|
131
|
+
fs.createWriteStream(destinationPath),
|
|
132
132
|
);
|
|
133
133
|
|
|
134
134
|
return res.headers.get('content-type') || '';
|
|
@@ -145,22 +145,75 @@ const downloadUrlToFile = async (urlString, destinationPath) => {
|
|
|
145
145
|
const message =
|
|
146
146
|
lastError && lastError.message ? lastError.message : String(lastError);
|
|
147
147
|
throw new Error(
|
|
148
|
-
`Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}
|
|
148
|
+
`Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`,
|
|
149
149
|
);
|
|
150
150
|
};
|
|
151
151
|
|
|
152
|
+
// Returns a human-readable reason string if the video file needs transcoding
|
|
153
|
+
// for Chromium headless compatibility, or null if it is already render-safe.
|
|
154
|
+
const getVideoNormalizationReason = (filePath) => {
|
|
155
|
+
try {
|
|
156
|
+
const raw = execSync(
|
|
157
|
+
`ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,codec_tag_string,pix_fmt -of json "${filePath}"`,
|
|
158
|
+
{ encoding: 'utf8' },
|
|
159
|
+
);
|
|
160
|
+
const info = JSON.parse(raw);
|
|
161
|
+
const vs = info?.streams?.[0];
|
|
162
|
+
if (!vs) return null;
|
|
163
|
+
|
|
164
|
+
const codec = (vs.codec_name || '').toLowerCase();
|
|
165
|
+
const tag = (vs.codec_tag_string || '').toLowerCase();
|
|
166
|
+
if (['hevc', 'h265'].includes(codec) || ['hvc1', 'hev1'].includes(tag)) {
|
|
167
|
+
return 'HEVC/H.265 is not supported by Chromium headless';
|
|
168
|
+
}
|
|
169
|
+
const pix = (vs.pix_fmt || '').toLowerCase();
|
|
170
|
+
if (pix && pix !== 'yuv420p') {
|
|
171
|
+
return `pixel format "${pix}" is not the preferred yuv420p`;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const isFfmpegAvailable = () => {
|
|
180
|
+
try {
|
|
181
|
+
execSync('ffmpeg -version', { stdio: 'ignore' });
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const getAudioCodec = (filePath) => {
|
|
189
|
+
try {
|
|
190
|
+
const result = execSync(
|
|
191
|
+
`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`,
|
|
192
|
+
{ encoding: 'utf8' },
|
|
193
|
+
);
|
|
194
|
+
return result.trim();
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const convertAudioToAac = (inputPath, outputPath) => {
|
|
201
|
+
execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
|
|
202
|
+
stdio: 'ignore',
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
152
206
|
const prepareLocalMediaForVideoExport = async (json) => {
|
|
153
207
|
const clonedJson = JSON.parse(JSON.stringify(json));
|
|
154
208
|
const pointers = collectMediaSourcePointers(clonedJson);
|
|
155
209
|
|
|
156
|
-
// Map original URL -> file://... URL
|
|
157
210
|
const rewriteMap = new Map();
|
|
158
211
|
const urlsToDownload = [];
|
|
212
|
+
const videoUrls = new Set();
|
|
159
213
|
|
|
160
214
|
for (const p of pointers) {
|
|
161
215
|
const src = p.target[p.key];
|
|
162
216
|
if (isDataOrFileSrc(src)) continue;
|
|
163
|
-
// Only download http/https sources (everything else is skipped)
|
|
164
217
|
try {
|
|
165
218
|
const u = new URL(src);
|
|
166
219
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
|
|
@@ -171,6 +224,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
|
|
|
171
224
|
rewriteMap.set(src, null);
|
|
172
225
|
urlsToDownload.push(src);
|
|
173
226
|
}
|
|
227
|
+
if (p.kind === 'video') videoUrls.add(src);
|
|
174
228
|
}
|
|
175
229
|
|
|
176
230
|
if (urlsToDownload.length === 0) {
|
|
@@ -178,7 +232,7 @@ const prepareLocalMediaForVideoExport = async (json) => {
|
|
|
178
232
|
}
|
|
179
233
|
|
|
180
234
|
const tempDir = await fs.promises.mkdtemp(
|
|
181
|
-
path.join(os.tmpdir(), 'polotno-node-assets-')
|
|
235
|
+
path.join(os.tmpdir(), 'polotno-node-assets-'),
|
|
182
236
|
);
|
|
183
237
|
|
|
184
238
|
let didCleanup = false;
|
|
@@ -189,21 +243,33 @@ const prepareLocalMediaForVideoExport = async (json) => {
|
|
|
189
243
|
};
|
|
190
244
|
|
|
191
245
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
246
|
+
await Promise.all(
|
|
247
|
+
urlsToDownload.map(async (src) => {
|
|
248
|
+
const tmpBase = crypto.randomUUID
|
|
249
|
+
? crypto.randomUUID()
|
|
250
|
+
: crypto.randomBytes(16).toString('hex');
|
|
251
|
+
const tmpPath = path.join(tempDir, tmpBase);
|
|
252
|
+
const contentType = await downloadUrlToFile(src, tmpPath);
|
|
253
|
+
const ext = guessExtension(src, contentType);
|
|
254
|
+
let finalPath = tmpPath + ext;
|
|
255
|
+
await fs.promises.rename(tmpPath, finalPath);
|
|
256
|
+
|
|
257
|
+
if (videoUrls.has(src)) {
|
|
258
|
+
const reason = getVideoNormalizationReason(finalPath);
|
|
259
|
+
if (reason) {
|
|
260
|
+
const normalizedPath = finalPath + '.normalized.mp4';
|
|
261
|
+
execSync(
|
|
262
|
+
`ffmpeg -y -i "${finalPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${normalizedPath}"`,
|
|
263
|
+
{ stdio: 'ignore' },
|
|
264
|
+
);
|
|
265
|
+
finalPath = normalizedPath;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
rewriteMap.set(src, pathToFileURL(finalPath).toString());
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
205
272
|
|
|
206
|
-
// Apply rewrites
|
|
207
273
|
for (const p of pointers) {
|
|
208
274
|
const src = p.target[p.key];
|
|
209
275
|
const replacement = rewriteMap.get(src);
|
|
@@ -279,7 +345,7 @@ module.exports.jsonToDataURL = async (page, json, attrs) => {
|
|
|
279
345
|
return store.toDataURL({ ...attrs, pixelRatio });
|
|
280
346
|
},
|
|
281
347
|
json,
|
|
282
|
-
attrs || {}
|
|
348
|
+
attrs || {},
|
|
283
349
|
);
|
|
284
350
|
};
|
|
285
351
|
|
|
@@ -291,7 +357,7 @@ module.exports.jsonToPDFDataURL = async (page, json, attrs) => {
|
|
|
291
357
|
return await store.toPDFDataURL(attrs);
|
|
292
358
|
},
|
|
293
359
|
json,
|
|
294
|
-
attrs || {}
|
|
360
|
+
attrs || {},
|
|
295
361
|
);
|
|
296
362
|
};
|
|
297
363
|
|
|
@@ -303,7 +369,7 @@ module.exports.jsonToBlob = async (page, json, attrs) => {
|
|
|
303
369
|
return await store.toBlob(attrs);
|
|
304
370
|
},
|
|
305
371
|
json,
|
|
306
|
-
attrs || {}
|
|
372
|
+
attrs || {},
|
|
307
373
|
);
|
|
308
374
|
};
|
|
309
375
|
|
|
@@ -382,7 +448,7 @@ module.exports.createInstance = async ({
|
|
|
382
448
|
|
|
383
449
|
if (busyPages.indexOf(page) >= 0) {
|
|
384
450
|
throw new Error(
|
|
385
|
-
'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.'
|
|
451
|
+
'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.',
|
|
386
452
|
);
|
|
387
453
|
}
|
|
388
454
|
|
|
@@ -404,7 +470,7 @@ module.exports.createInstance = async ({
|
|
|
404
470
|
window.config.setAssetLoadTimeout(timeout);
|
|
405
471
|
} else {
|
|
406
472
|
console.error(
|
|
407
|
-
'setAssetLoadTimeout function is not defined in the client.'
|
|
473
|
+
'setAssetLoadTimeout function is not defined in the client.',
|
|
408
474
|
);
|
|
409
475
|
}
|
|
410
476
|
}, args[1].assetLoadTimeout);
|
|
@@ -415,7 +481,7 @@ module.exports.createInstance = async ({
|
|
|
415
481
|
window.config.setFontLoadTimeout(timeout);
|
|
416
482
|
} else {
|
|
417
483
|
console.error(
|
|
418
|
-
'setFontLoadTimeout function is not defined in the client.'
|
|
484
|
+
'setFontLoadTimeout function is not defined in the client.',
|
|
419
485
|
);
|
|
420
486
|
}
|
|
421
487
|
}, args[1].fontLoadTimeout);
|
|
@@ -426,7 +492,7 @@ module.exports.createInstance = async ({
|
|
|
426
492
|
window.config.setRichTextEnabled(true);
|
|
427
493
|
} else {
|
|
428
494
|
console.error(
|
|
429
|
-
'setRichTextEnabled function is not defined in the client.'
|
|
495
|
+
'setRichTextEnabled function is not defined in the client.',
|
|
430
496
|
);
|
|
431
497
|
}
|
|
432
498
|
});
|
|
@@ -437,7 +503,7 @@ module.exports.createInstance = async ({
|
|
|
437
503
|
window.config.setTextVerticalResizeEnabled(true);
|
|
438
504
|
} else {
|
|
439
505
|
console.error(
|
|
440
|
-
'setTextVerticalResizeEnabled function is not defined in the client.'
|
|
506
|
+
'setTextVerticalResizeEnabled function is not defined in the client.',
|
|
441
507
|
);
|
|
442
508
|
}
|
|
443
509
|
});
|
|
@@ -448,7 +514,7 @@ module.exports.createInstance = async ({
|
|
|
448
514
|
window.config.unstable_setTextSplitAllowed(true);
|
|
449
515
|
} else {
|
|
450
516
|
console.error(
|
|
451
|
-
'unstable_setTextSplitAllowed function is not defined in the client.'
|
|
517
|
+
'unstable_setTextSplitAllowed function is not defined in the client.',
|
|
452
518
|
);
|
|
453
519
|
}
|
|
454
520
|
});
|
|
@@ -471,7 +537,7 @@ module.exports.createInstance = async ({
|
|
|
471
537
|
window.config.setTextOverflow(overflow);
|
|
472
538
|
} else {
|
|
473
539
|
console.error(
|
|
474
|
-
'setTextOverflow function is not defined in the client.'
|
|
540
|
+
'setTextOverflow function is not defined in the client.',
|
|
475
541
|
);
|
|
476
542
|
}
|
|
477
543
|
}, args[1].textOverflow);
|
|
@@ -483,7 +549,7 @@ module.exports.createInstance = async ({
|
|
|
483
549
|
});
|
|
484
550
|
} else {
|
|
485
551
|
console.error(
|
|
486
|
-
'onLoadError function is not defined in the client. Error handling will not work.'
|
|
552
|
+
'onLoadError function is not defined in the client. Error handling will not work.',
|
|
487
553
|
);
|
|
488
554
|
}
|
|
489
555
|
});
|
|
@@ -499,7 +565,7 @@ module.exports.createInstance = async ({
|
|
|
499
565
|
|
|
500
566
|
const assetErrorMessage = await consumeAssetLoadingErrorMessage(
|
|
501
567
|
page,
|
|
502
|
-
args[1]
|
|
568
|
+
args[1],
|
|
503
569
|
);
|
|
504
570
|
if (assetErrorMessage) {
|
|
505
571
|
throw new Error(assetErrorMessage);
|
|
@@ -527,7 +593,7 @@ module.exports.createInstance = async ({
|
|
|
527
593
|
// a captured Polotno asset-loading error when available.
|
|
528
594
|
const assetErrorMessage = await consumeAssetLoadingErrorMessage(
|
|
529
595
|
page,
|
|
530
|
-
args[1]
|
|
596
|
+
args[1],
|
|
531
597
|
);
|
|
532
598
|
// remove busy page
|
|
533
599
|
busyPages.splice(busyPages.indexOf(page), 1);
|
|
@@ -551,7 +617,7 @@ module.exports.createInstance = async ({
|
|
|
551
617
|
return store.toDataURL({ ...attrs, pixelRatio });
|
|
552
618
|
},
|
|
553
619
|
json,
|
|
554
|
-
attrs || {}
|
|
620
|
+
attrs || {},
|
|
555
621
|
);
|
|
556
622
|
};
|
|
557
623
|
|
|
@@ -562,7 +628,7 @@ module.exports.createInstance = async ({
|
|
|
562
628
|
return await store.toGIFDataURL(attrs);
|
|
563
629
|
},
|
|
564
630
|
json,
|
|
565
|
-
attrs || {}
|
|
631
|
+
attrs || {},
|
|
566
632
|
);
|
|
567
633
|
};
|
|
568
634
|
|
|
@@ -589,7 +655,7 @@ module.exports.createInstance = async ({
|
|
|
589
655
|
return await store.toPDFDataURL(attrs);
|
|
590
656
|
},
|
|
591
657
|
json,
|
|
592
|
-
attrs || {}
|
|
658
|
+
attrs || {},
|
|
593
659
|
);
|
|
594
660
|
};
|
|
595
661
|
|
|
@@ -604,33 +670,6 @@ module.exports.createInstance = async ({
|
|
|
604
670
|
return blob;
|
|
605
671
|
};
|
|
606
672
|
|
|
607
|
-
const isFfmpegAvailable = () => {
|
|
608
|
-
try {
|
|
609
|
-
execSync('ffmpeg -version', { stdio: 'ignore' });
|
|
610
|
-
return true;
|
|
611
|
-
} catch {
|
|
612
|
-
return false;
|
|
613
|
-
}
|
|
614
|
-
};
|
|
615
|
-
|
|
616
|
-
const getAudioCodec = (filePath) => {
|
|
617
|
-
try {
|
|
618
|
-
const result = execSync(
|
|
619
|
-
`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`,
|
|
620
|
-
{ encoding: 'utf8' }
|
|
621
|
-
);
|
|
622
|
-
return result.trim();
|
|
623
|
-
} catch {
|
|
624
|
-
return null;
|
|
625
|
-
}
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
const convertAudioToAac = (inputPath, outputPath) => {
|
|
629
|
-
execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
|
|
630
|
-
stdio: 'ignore',
|
|
631
|
-
});
|
|
632
|
-
};
|
|
633
|
-
|
|
634
673
|
const jsonToVideoFile = async (json, outputPath, attrs = {}) => {
|
|
635
674
|
const shouldDownloadMedia =
|
|
636
675
|
!hasCustomClientUrl && !(attrs && attrs.skipDownloads);
|
|
@@ -670,7 +709,7 @@ module.exports.createInstance = async ({
|
|
|
670
709
|
|
|
671
710
|
if (!window.__polotnoLoadVideoExport) {
|
|
672
711
|
throw new Error(
|
|
673
|
-
'Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().'
|
|
712
|
+
'Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().',
|
|
674
713
|
);
|
|
675
714
|
}
|
|
676
715
|
|
|
@@ -751,7 +790,7 @@ module.exports.createInstance = async ({
|
|
|
751
790
|
writeStream.write(buffer);
|
|
752
791
|
},
|
|
753
792
|
},
|
|
754
|
-
}
|
|
793
|
+
},
|
|
755
794
|
);
|
|
756
795
|
|
|
757
796
|
writeStream.end();
|
|
@@ -776,13 +815,13 @@ module.exports.createInstance = async ({
|
|
|
776
815
|
const jsonToVideoDataURL = async (json, attrs) => {
|
|
777
816
|
const tempPath = path.join(
|
|
778
817
|
os.tmpdir(),
|
|
779
|
-
`polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4
|
|
818
|
+
`polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`,
|
|
780
819
|
);
|
|
781
820
|
try {
|
|
782
821
|
const mimeType = await jsonToVideoFile(json, tempPath, attrs);
|
|
783
822
|
const buffer = await fs.promises.readFile(tempPath);
|
|
784
823
|
return `data:${mimeType || 'video/mp4'};base64,${buffer.toString(
|
|
785
|
-
'base64'
|
|
824
|
+
'base64',
|
|
786
825
|
)}`;
|
|
787
826
|
} finally {
|
|
788
827
|
await fs.promises.rm(tempPath, { force: true });
|
|
@@ -792,7 +831,7 @@ module.exports.createInstance = async ({
|
|
|
792
831
|
const jsonToVideoBase64 = async (json, attrs) => {
|
|
793
832
|
const tempPath = path.join(
|
|
794
833
|
os.tmpdir(),
|
|
795
|
-
`polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4
|
|
834
|
+
`polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`,
|
|
796
835
|
);
|
|
797
836
|
try {
|
|
798
837
|
await jsonToVideoFile(json, tempPath, attrs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polotno-node",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.16",
|
|
4
4
|
"description": "Polotno workflow from NodeJS",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
"patch-package": "^8.0.1",
|
|
31
31
|
"pixelmatch": "^7.1.0",
|
|
32
32
|
"pngjs": "^7.0.0",
|
|
33
|
-
"polotno": "^2.38.2",
|
|
34
33
|
"react": "^18.3.1",
|
|
35
34
|
"react-dom": "^18.3.1",
|
|
36
35
|
"vite": "^8.0.0",
|
|
@@ -41,6 +40,7 @@
|
|
|
41
40
|
"license": "MIT",
|
|
42
41
|
"dependencies": {
|
|
43
42
|
"@sparticuz/chromium": "^143.0.4",
|
|
43
|
+
"polotno": "^2.38.2",
|
|
44
44
|
"puppeteer": "24.39.1",
|
|
45
45
|
"puppeteer-core": "24.39.1",
|
|
46
46
|
"tmp": "^0.2.5"
|