polotno-node 2.14.12 → 2.15.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/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,127 @@ 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 via exposed function to avoid large payload issues.
694
+ // CRITICAL (!!!): Chunk size MUST be a multiple of 3 bytes because base64 encodes
695
+ // 3 bytes -> 4 characters. If chunks aren't aligned, each chunk gets padding
696
+ // (= or ==) at the end, and joining them creates invalid base64 and video may corrupt.
697
+ const CHUNK_SIZE = 3 * 1024 * 1024; // 3MB, divisible by 3
698
+ const arrayBuffer = await videoBlob.arrayBuffer();
699
+ const uint8Array = new Uint8Array(arrayBuffer);
700
+
701
+ for (
702
+ let offset = 0;
703
+ offset < uint8Array.length;
704
+ offset += CHUNK_SIZE
705
+ ) {
706
+ const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
707
+ const chunk = uint8Array.slice(offset, end);
708
+ let binary = '';
709
+ for (let i = 0; i < chunk.length; i++) {
710
+ binary += String.fromCharCode(chunk[i]);
711
+ }
712
+ await window.__polotnoSendChunk(btoa(binary));
479
713
  }
480
- await window.__polotnoSendChunk(btoa(binary));
481
- }
482
714
 
483
- return videoBlob.type;
484
- },
485
- json,
486
- {
487
- ...attrs,
488
- exposeFunctions: {
489
- __polotnoSendChunk: (chunk) => chunks.push(chunk),
715
+ return videoBlob.type;
490
716
  },
491
- }
492
- );
493
- return `data:${mimeType || 'video/mp4'};base64,${chunks.join('')}`;
717
+ prepared.json,
718
+ {
719
+ ...attrs,
720
+ exposeFunctions: {
721
+ __polotnoSendChunk: (chunk) => chunks.push(chunk),
722
+ },
723
+ }
724
+ );
725
+ return `data:${mimeType || 'video/mp4'};base64,${chunks.join('')}`;
726
+ } finally {
727
+ await prepared.cleanup();
728
+ }
494
729
  };
495
730
 
496
731
  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.1",
4
4
  "description": "Polotno workflow from NodeJS",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",