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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/instance.js +107 -68
  3. 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
- for (const src of urlsToDownload) {
193
- const tmpBase = crypto.randomUUID
194
- ? crypto.randomUUID()
195
- : crypto.randomBytes(16).toString('hex');
196
- // First download to a temp file without extension to know content-type
197
- const tmpPath = path.join(tempDir, tmpBase);
198
- const contentType = await downloadUrlToFile(src, tmpPath);
199
- const ext = guessExtension(src, contentType);
200
- const finalPath = tmpPath + ext;
201
- // rename for nicer file URLs and better sniffing
202
- await fs.promises.rename(tmpPath, finalPath);
203
- rewriteMap.set(src, pathToFileURL(finalPath).toString());
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.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"