polotno-node 2.14.12 → 2.15.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 CHANGED
@@ -245,6 +245,22 @@ const url = await instance.jsonToDataURL(json, { pixelRatio: 0.2 });
245
245
  // const url = await store.toDataURL({ pixelRatio: 0.2 });
246
246
  ```
247
247
 
248
+ ### `attrs.skipDownloads` (video export only)
249
+
250
+ By default, when exporting video and **only** when using the default local client (i.e. you did **not** pass `createInstance({ url })`), `polotno-node` will:
251
+
252
+ - Download all **remote** video elements (`type: 'video'`) and `json.audios` sources into a temporary folder
253
+ - Deduplicate downloads (same URL is downloaded once)
254
+ - Rewrite `src` to `file://...` URLs so Chromium loads media from disk
255
+
256
+ If you want to disable this behavior, set:
257
+
258
+ ```js
259
+ const url = await instance.jsonToVideoDataURL(json, {
260
+ skipDownloads: true,
261
+ });
262
+ ```
263
+
248
264
  ### `attrs.assetLoadTimeout`
249
265
 
250
266
  You can add `assetLoadTimeout` attribute to `attrs` object. It will be used to set timeout for loading assets. By default it is 30000ms.
package/index.d.ts CHANGED
@@ -34,6 +34,12 @@ declare module 'polotno-node' {
34
34
  fps?: number;
35
35
  pixelRatio?: number;
36
36
  onProgress?: (progress: number, frameTime?: number) => void;
37
+ /**
38
+ * When using the default local client (no `createInstance({ url })`), polotno-node will
39
+ * pre-download remote video/audio sources into a temp folder and rewrite them to `file://...`
40
+ * URLs for more reliable and faster loading. Set `skipDownloads: true` to disable that.
41
+ */
42
+ skipDownloads?: boolean;
37
43
  }
38
44
 
39
45
  // JSON Structure for Rendering
package/instance.js CHANGED
@@ -1,7 +1,223 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const { pathToFileURL } = require('url');
6
+ const { Readable } = require('stream');
7
+ const { pipeline } = require('stream/promises');
3
8
  const DEFAULT_CLIENT = `file:${path.join(__dirname, 'dist', 'index.html')}`;
4
9
 
10
+ // Walk Polotno nodes recursively (depth-first)
11
+ // cb(node) may return true to stop traversing the current branch early
12
+ const forEveryChild = (node, cb) => {
13
+ if (!node || !node.children) return;
14
+ for (const child of node.children) {
15
+ const shouldStop = cb(child);
16
+ if (shouldStop === true) {
17
+ break;
18
+ }
19
+ forEveryChild(child, cb);
20
+ }
21
+ };
22
+
23
+ // Collect pointers to all media objects that can have `src` rewritten.
24
+ // Returned entries look like: { kind: 'video'|'audio', target: <object>, key: 'src' }
25
+ const collectMediaSourcePointers = (json) => {
26
+ const pointers = [];
27
+
28
+ // videos in elements tree
29
+ for (const page of json?.pages || []) {
30
+ for (const el of page?.children || []) {
31
+ if (el?.type === 'video' && typeof el.src === 'string') {
32
+ pointers.push({ kind: 'video', target: el, key: 'src' });
33
+ }
34
+ forEveryChild(el, (child) => {
35
+ if (child?.type === 'video' && typeof child.src === 'string') {
36
+ pointers.push({ kind: 'video', target: child, key: 'src' });
37
+ }
38
+ });
39
+ }
40
+ }
41
+
42
+ // audios array
43
+ for (const audio of json?.audios || []) {
44
+ if (audio && typeof audio.src === 'string') {
45
+ pointers.push({ kind: 'audio', target: audio, key: 'src' });
46
+ }
47
+ }
48
+
49
+ return pointers;
50
+ };
51
+
52
+ const isDataOrFileSrc = (src) => {
53
+ if (typeof src !== 'string') return true;
54
+ return (
55
+ src.startsWith('data:') ||
56
+ src.startsWith('file:') ||
57
+ src.startsWith('blob:')
58
+ );
59
+ };
60
+
61
+ const guessExtension = (urlString, contentType) => {
62
+ try {
63
+ const u = new URL(urlString);
64
+ const ext = path.extname(u.pathname || '');
65
+ if (ext) return ext;
66
+ } catch (e) {
67
+ // ignore; fallback to content-type
68
+ }
69
+
70
+ const ct = String(contentType || '')
71
+ .split(';')[0]
72
+ .trim()
73
+ .toLowerCase();
74
+ const map = {
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
+ };
85
+ return map[ct] || '.bin';
86
+ };
87
+
88
+ const downloadUrlToFile = async (urlString, destinationPath) => {
89
+ if (typeof fetch !== 'function') {
90
+ throw new Error(
91
+ 'Global fetch API is not available in this Node.js runtime. Please use Node 18+.'
92
+ );
93
+ }
94
+ const MAX_ATTEMPTS = 3;
95
+ const BASE_DELAY_MS = 300;
96
+ let lastError = null;
97
+
98
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
99
+ try {
100
+ // Ensure no partial file exists from previous attempts
101
+ await fs.promises.rm(destinationPath, { force: true });
102
+
103
+ const res = await fetch(urlString);
104
+ if (!res.ok) {
105
+ // Best-effort read of error body (small) for debugging.
106
+ let bodyPreview = '';
107
+ try {
108
+ bodyPreview = await res.text();
109
+ if (bodyPreview.length > 500) {
110
+ bodyPreview = bodyPreview.slice(0, 500) + '...';
111
+ }
112
+ } catch (e) {
113
+ // ignore
114
+ }
115
+ const details = [
116
+ `${res.status} ${res.statusText}`.trim(),
117
+ bodyPreview ? `body: ${JSON.stringify(bodyPreview)}` : null,
118
+ ]
119
+ .filter(Boolean)
120
+ .join(', ');
121
+
122
+ throw new Error(`HTTP error while downloading: ${details}`);
123
+ }
124
+ if (!res.body) {
125
+ throw new Error('Empty response body');
126
+ }
127
+
128
+ await pipeline(
129
+ Readable.fromWeb(res.body),
130
+ fs.createWriteStream(destinationPath)
131
+ );
132
+
133
+ return res.headers.get('content-type') || '';
134
+ } catch (e) {
135
+ lastError = e;
136
+ if (attempt < MAX_ATTEMPTS) {
137
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
138
+ await new Promise((r) => setTimeout(r, delay));
139
+ continue;
140
+ }
141
+ }
142
+ }
143
+
144
+ const message =
145
+ lastError && lastError.message ? lastError.message : String(lastError);
146
+ throw new Error(
147
+ `Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`
148
+ );
149
+ };
150
+
151
+ const prepareLocalMediaForVideoExport = async (json) => {
152
+ const clonedJson = JSON.parse(JSON.stringify(json));
153
+ const pointers = collectMediaSourcePointers(clonedJson);
154
+
155
+ // Map original URL -> file://... URL
156
+ const rewriteMap = new Map();
157
+ const urlsToDownload = [];
158
+
159
+ for (const p of pointers) {
160
+ const src = p.target[p.key];
161
+ if (isDataOrFileSrc(src)) continue;
162
+ // Only download http/https sources (everything else is skipped)
163
+ try {
164
+ const u = new URL(src);
165
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
166
+ } catch (e) {
167
+ continue;
168
+ }
169
+ if (!rewriteMap.has(src)) {
170
+ rewriteMap.set(src, null);
171
+ urlsToDownload.push(src);
172
+ }
173
+ }
174
+
175
+ if (urlsToDownload.length === 0) {
176
+ return { json: clonedJson, cleanup: async () => {} };
177
+ }
178
+
179
+ const tempDir = await fs.promises.mkdtemp(
180
+ path.join(os.tmpdir(), 'polotno-node-assets-')
181
+ );
182
+
183
+ let didCleanup = false;
184
+ const cleanup = async () => {
185
+ if (didCleanup) return;
186
+ didCleanup = true;
187
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
188
+ };
189
+
190
+ try {
191
+ for (const src of urlsToDownload) {
192
+ const tmpBase = crypto.randomUUID
193
+ ? crypto.randomUUID()
194
+ : crypto.randomBytes(16).toString('hex');
195
+ // First download to a temp file without extension to know content-type
196
+ const tmpPath = path.join(tempDir, tmpBase);
197
+ const contentType = await downloadUrlToFile(src, tmpPath);
198
+ const ext = guessExtension(src, contentType);
199
+ const finalPath = tmpPath + ext;
200
+ // rename for nicer file URLs and better sniffing
201
+ await fs.promises.rename(tmpPath, finalPath);
202
+ rewriteMap.set(src, pathToFileURL(finalPath).toString());
203
+ }
204
+
205
+ // Apply rewrites
206
+ for (const p of pointers) {
207
+ const src = p.target[p.key];
208
+ const replacement = rewriteMap.get(src);
209
+ if (replacement) {
210
+ p.target[p.key] = replacement;
211
+ }
212
+ }
213
+
214
+ return { json: clonedJson, cleanup };
215
+ } catch (e) {
216
+ await cleanup();
217
+ throw e;
218
+ }
219
+ };
220
+
5
221
  module.exports.createPage = async (browser, url, requestInterceptor) => {
6
222
  const page = await browser.newPage();
7
223
 
@@ -102,6 +318,7 @@ module.exports.createInstance = async ({
102
318
  requestInterceptor,
103
319
  } = {}) => {
104
320
  const visitPage = url || `${DEFAULT_CLIENT}?key=${key}`;
321
+ const hasCustomClientUrl = Boolean(url);
105
322
  const firstPage = useParallelPages
106
323
  ? null
107
324
  : await module.exports.createPage(browser, visitPage, requestInterceptor);
@@ -388,109 +605,124 @@ module.exports.createInstance = async ({
388
605
 
389
606
  const jsonToVideoDataURL = async (json, attrs) => {
390
607
  const chunks = [];
391
- const mimeType = await run(
392
- async (json, attrs) => {
393
- const pixelRatio = attrs.pixelRatio || 1;
394
- store.loadJSON(json);
395
- await store.waitLoading();
396
- window.__polotnoThrowAssetErrorIfAny(attrs);
608
+ const shouldDownloadMedia =
609
+ !hasCustomClientUrl && !(attrs && attrs.skipDownloads);
397
610
 
398
- // keep store internals consistent with image/pdf exports
399
- store.setElementsPixelRatio(pixelRatio);
611
+ const prepared = shouldDownloadMedia
612
+ ? await prepareLocalMediaForVideoExport(json)
613
+ : { json, cleanup: async () => {} };
400
614
 
401
- // loop through all pages and wait for loading for all layout calculations
402
- // (this helps stabilize text/layout across multi-page designs)
403
- for (const page of store.pages) {
404
- store.selectPage(page.id);
405
- await store.waitLoading();
406
- window.__polotnoThrowAssetErrorIfAny(attrs);
407
- }
408
- if (store.pages.length > 0) {
409
- store.selectPage(store.pages[0].id);
615
+ try {
616
+ const mimeType = await run(
617
+ async (json, attrs) => {
618
+ const pixelRatio = attrs.pixelRatio || 1;
619
+ store.loadJSON(json);
410
620
  await store.waitLoading();
411
621
  window.__polotnoThrowAssetErrorIfAny(attrs);
412
- }
413
622
 
414
- // For video export, we always use 'resize' text overflow mode
415
- // to ensure text fits properly in animations. User preferences are ignored for now.
416
- window.config.setTextOverflow('resize');
623
+ // keep store internals consistent with image/pdf exports
624
+ store.setElementsPixelRatio(pixelRatio);
417
625
 
418
- if (!window.loadVideoExportModule) {
419
- throw new Error(
420
- 'Video export module loader is not defined in the client. Expected window.loadVideoExportModule().'
421
- );
422
- }
626
+ // loop through all pages and wait for loading for all layout calculations
627
+ // (this helps stabilize text/layout across multi-page designs)
628
+ for (const page of store.pages) {
629
+ store.selectPage(page.id);
630
+ await store.waitLoading();
631
+ window.__polotnoThrowAssetErrorIfAny(attrs);
632
+ }
633
+ if (store.pages.length > 0) {
634
+ store.selectPage(store.pages[0].id);
635
+ await store.waitLoading();
636
+ window.__polotnoThrowAssetErrorIfAny(attrs);
637
+ }
638
+
639
+ // For video export, we always use 'resize' text overflow mode
640
+ // to ensure text fits properly in animations. User preferences are ignored for now.
641
+ window.config.setTextOverflow('resize');
423
642
 
424
- const { storeToVideo } = await window.loadVideoExportModule();
425
-
426
- // Create abort controller to cancel video render on error
427
- const abortController = new AbortController();
428
- let capturedError = null;
429
-
430
- // Always have an internal progress callback to check for errors,
431
- // even if user didn't provide onProgress
432
- const progressCallback = (progress, frameTime) => {
433
- // Check for asset-loading errors during render (without throwing)
434
- const errorMessage = window.__polotnoConsumeAssetError(attrs);
435
- if (errorMessage) {
436
- capturedError = errorMessage;
437
- abortController.abort();
438
- return;
643
+ if (!window.loadVideoExportModule) {
644
+ throw new Error(
645
+ 'Video export module loader is not defined in the client. Expected window.loadVideoExportModule().'
646
+ );
439
647
  }
440
- // Call user's progress callback if provided
441
- if (window.onProgress) {
442
- window.onProgress(progress, frameTime);
648
+
649
+ const { storeToVideo } = await window.loadVideoExportModule();
650
+
651
+ // Create abort controller to cancel video render on error
652
+ const abortController = new AbortController();
653
+ let capturedError = null;
654
+
655
+ // Always have an internal progress callback to check for errors,
656
+ // even if user didn't provide onProgress
657
+ const progressCallback = (progress, frameTime) => {
658
+ // Check for asset-loading errors during render (without throwing)
659
+ const errorMessage = window.__polotnoConsumeAssetError(attrs);
660
+ if (errorMessage) {
661
+ capturedError = errorMessage;
662
+ abortController.abort();
663
+ return;
664
+ }
665
+ // Call user's progress callback if provided
666
+ if (window.onProgress) {
667
+ window.onProgress(progress, frameTime);
668
+ }
669
+ };
670
+
671
+ let videoBlob;
672
+ try {
673
+ videoBlob = await storeToVideo({
674
+ store,
675
+ fps: attrs.fps,
676
+ pixelRatio,
677
+ onProgress: progressCallback,
678
+ signal: abortController.signal,
679
+ });
680
+ } catch (e) {
681
+ // If we aborted due to asset error, throw that instead
682
+ if (capturedError) {
683
+ throw new Error(capturedError);
684
+ }
685
+ throw e;
443
686
  }
444
- };
445
687
 
446
- let videoBlob;
447
- try {
448
- videoBlob = await storeToVideo({
449
- store,
450
- fps: attrs.fps,
451
- pixelRatio,
452
- onProgress: progressCallback,
453
- signal: abortController.signal,
454
- });
455
- } catch (e) {
456
- // If we aborted due to asset error, throw that instead
688
+ // Final check for any errors that occurred
457
689
  if (capturedError) {
458
690
  throw new Error(capturedError);
459
691
  }
460
- throw e;
461
- }
462
-
463
- // Final check for any errors that occurred
464
- if (capturedError) {
465
- throw new Error(capturedError);
466
- }
467
692
 
468
- // Stream blob in chunks (5MB each) via exposed function to avoid large payload issues
469
- const CHUNK_SIZE = 5 * 1024 * 1024;
470
- const arrayBuffer = await videoBlob.arrayBuffer();
471
- const uint8Array = new Uint8Array(arrayBuffer);
472
-
473
- for (let offset = 0; offset < uint8Array.length; offset += CHUNK_SIZE) {
474
- const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
475
- const chunk = uint8Array.slice(offset, end);
476
- let binary = '';
477
- for (let i = 0; i < chunk.length; i++) {
478
- binary += String.fromCharCode(chunk[i]);
693
+ // Stream blob in chunks (5MB each) via exposed function to avoid large payload issues
694
+ const CHUNK_SIZE = 5 * 1024 * 1024;
695
+ const arrayBuffer = await videoBlob.arrayBuffer();
696
+ const uint8Array = new Uint8Array(arrayBuffer);
697
+
698
+ for (
699
+ let offset = 0;
700
+ offset < uint8Array.length;
701
+ offset += CHUNK_SIZE
702
+ ) {
703
+ const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
704
+ const chunk = uint8Array.slice(offset, end);
705
+ let binary = '';
706
+ for (let i = 0; i < chunk.length; i++) {
707
+ binary += String.fromCharCode(chunk[i]);
708
+ }
709
+ await window.__polotnoSendChunk(btoa(binary));
479
710
  }
480
- await window.__polotnoSendChunk(btoa(binary));
481
- }
482
711
 
483
- return videoBlob.type;
484
- },
485
- json,
486
- {
487
- ...attrs,
488
- exposeFunctions: {
489
- __polotnoSendChunk: (chunk) => chunks.push(chunk),
712
+ return videoBlob.type;
490
713
  },
491
- }
492
- );
493
- return `data:${mimeType || 'video/mp4'};base64,${chunks.join('')}`;
714
+ prepared.json,
715
+ {
716
+ ...attrs,
717
+ exposeFunctions: {
718
+ __polotnoSendChunk: (chunk) => chunks.push(chunk),
719
+ },
720
+ }
721
+ );
722
+ return `data:${mimeType || 'video/mp4'};base64,${chunks.join('')}`;
723
+ } finally {
724
+ await prepared.cleanup();
725
+ }
494
726
  };
495
727
 
496
728
  const jsonToVideoBase64 = async (json, attrs) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polotno-node",
3
- "version": "2.14.12",
3
+ "version": "2.15.0",
4
4
  "description": "Polotno workflow from NodeJS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",