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 +16 -0
- package/index.d.ts +6 -0
- package/instance.js +318 -86
- package/package.json +1 -1
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
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
484
|
-
},
|
|
485
|
-
json,
|
|
486
|
-
{
|
|
487
|
-
...attrs,
|
|
488
|
-
exposeFunctions: {
|
|
489
|
-
__polotnoSendChunk: (chunk) => chunks.push(chunk),
|
|
712
|
+
return videoBlob.type;
|
|
490
713
|
},
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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) => {
|