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/README.md +16 -0
- package/dist/assets/{index-Xal09T7W.js → index-Ca74A7-l.js} +103 -103
- package/dist/assets/index-SXZWmjCL.js +65 -0
- package/dist/assets/{index.es-6B4V2Olh.js → index.es-DsqSHMpS.js} +1 -1
- package/dist/index.html +1 -1
- package/index.d.ts +6 -0
- package/instance.js +321 -86
- package/package.json +1 -1
- package/dist/assets/index-D5GpnOg5.js +0 -65
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
|
|
392
|
-
|
|
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
|
-
|
|
399
|
-
|
|
611
|
+
const prepared = shouldDownloadMedia
|
|
612
|
+
? await prepareLocalMediaForVideoExport(json)
|
|
613
|
+
: { json, cleanup: async () => {} };
|
|
400
614
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
window.config.setTextOverflow('resize');
|
|
623
|
+
// keep store internals consistent with image/pdf exports
|
|
624
|
+
store.setElementsPixelRatio(pixelRatio);
|
|
417
625
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
484
|
-
},
|
|
485
|
-
json,
|
|
486
|
-
{
|
|
487
|
-
...attrs,
|
|
488
|
-
exposeFunctions: {
|
|
489
|
-
__polotnoSendChunk: (chunk) => chunks.push(chunk),
|
|
715
|
+
return videoBlob.type;
|
|
490
716
|
},
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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) => {
|