polotno-node 2.15.19 → 3.0.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.
@@ -0,0 +1,740 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import crypto from "crypto";
5
+ import { execSync } from "child_process";
6
+ import { pathToFileURL, fileURLToPath } from "url";
7
+ import { Readable } from "stream";
8
+ import { pipeline } from "stream/promises";
9
+ import { assertValidDesign } from "@polotno/schema";
10
+ import * as fontCache from "./font-cache.js";
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ // Compiled to lib/instance.js; the client bundle is published to ../dist.
13
+ const DEFAULT_CLIENT = `file:${path.join(__dirname, "..", "dist", "index.html")}`;
14
+ // Walk Polotno nodes recursively (depth-first)
15
+ // cb(node) may return true to stop traversing the current branch early
16
+ const forEveryChild = (node, cb) => {
17
+ if (!node || !node.children)
18
+ return;
19
+ for (const child of node.children) {
20
+ const shouldStop = cb(child);
21
+ if (shouldStop === true) {
22
+ break;
23
+ }
24
+ forEveryChild(child, cb);
25
+ }
26
+ };
27
+ // Collect pointers to all media objects that can have `src` rewritten.
28
+ // Returned entries look like: { kind: 'video'|'audio', target: <object>, key: 'src' }
29
+ const collectMediaSourcePointers = (json) => {
30
+ const pointers = [];
31
+ // videos in elements tree
32
+ for (const page of json?.pages || []) {
33
+ for (const el of page?.children || []) {
34
+ if (el?.type === "video" && typeof el.src === "string") {
35
+ pointers.push({ kind: "video", target: el, key: "src" });
36
+ }
37
+ forEveryChild(el, (child) => {
38
+ if (child?.type === "video" && typeof child.src === "string") {
39
+ pointers.push({ kind: "video", target: child, key: "src" });
40
+ }
41
+ });
42
+ }
43
+ }
44
+ // audios array
45
+ for (const audio of json?.audios || []) {
46
+ if (audio && typeof audio.src === "string") {
47
+ pointers.push({ kind: "audio", target: audio, key: "src" });
48
+ }
49
+ }
50
+ return pointers;
51
+ };
52
+ const isDataOrFileSrc = (src) => {
53
+ if (typeof src !== "string")
54
+ return true;
55
+ return (src.startsWith("data:") ||
56
+ src.startsWith("file:") ||
57
+ src.startsWith("blob:"));
58
+ };
59
+ const guessExtension = (urlString, contentType) => {
60
+ try {
61
+ const u = new URL(urlString);
62
+ const ext = path.extname(u.pathname || "");
63
+ if (ext)
64
+ return ext;
65
+ }
66
+ catch (e) {
67
+ // ignore; fallback to content-type
68
+ }
69
+ const ct = String(contentType || "")
70
+ .split(";")[0]
71
+ .trim()
72
+ .toLowerCase();
73
+ const map = {
74
+ "video/mp4": ".mp4",
75
+ "video/webm": ".webm",
76
+ "video/quicktime": ".mov",
77
+ "audio/mpeg": ".mp3",
78
+ "audio/mp4": ".m4a",
79
+ "audio/wav": ".wav",
80
+ "audio/x-wav": ".wav",
81
+ "audio/ogg": ".ogg",
82
+ "audio/webm": ".webm",
83
+ };
84
+ return map[ct] || ".bin";
85
+ };
86
+ const downloadUrlToFile = async (urlString, destinationPath) => {
87
+ if (typeof fetch !== "function") {
88
+ throw new Error("Global fetch API is not available in this Node.js runtime. Please use Node 18+.");
89
+ }
90
+ const MAX_ATTEMPTS = 3;
91
+ const BASE_DELAY_MS = 300;
92
+ let lastError = null;
93
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
94
+ try {
95
+ // Ensure no partial file exists from previous attempts
96
+ await fs.promises.rm(destinationPath, { force: true });
97
+ const res = await fetch(urlString);
98
+ if (!res.ok) {
99
+ // Best-effort read of error body (small) for debugging.
100
+ let bodyPreview = "";
101
+ try {
102
+ bodyPreview = await res.text();
103
+ if (bodyPreview.length > 500) {
104
+ bodyPreview = bodyPreview.slice(0, 500) + "...";
105
+ }
106
+ }
107
+ catch (e) {
108
+ // ignore
109
+ }
110
+ const details = [
111
+ `${res.status} ${res.statusText}`.trim(),
112
+ bodyPreview ? `body: ${JSON.stringify(bodyPreview)}` : null,
113
+ ]
114
+ .filter(Boolean)
115
+ .join(", ");
116
+ throw new Error(`HTTP error while downloading: ${details}`);
117
+ }
118
+ if (!res.body) {
119
+ throw new Error("Empty response body");
120
+ }
121
+ await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destinationPath));
122
+ return res.headers.get("content-type") || "";
123
+ }
124
+ catch (e) {
125
+ lastError = e;
126
+ if (attempt < MAX_ATTEMPTS) {
127
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
128
+ await new Promise((r) => setTimeout(r, delay));
129
+ continue;
130
+ }
131
+ }
132
+ }
133
+ const message = lastError && lastError.message ? lastError.message : String(lastError);
134
+ throw new Error(`Failed to download media after ${MAX_ATTEMPTS} attempts: ${urlString}\nLast error: ${message}`);
135
+ };
136
+ // Returns a human-readable reason string if the video file needs transcoding
137
+ // for Chromium headless compatibility, or null if it is already render-safe.
138
+ const getVideoNormalizationReason = (filePath) => {
139
+ try {
140
+ const raw = execSync(`ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,codec_tag_string,pix_fmt -of json "${filePath}"`, { encoding: "utf8" });
141
+ const info = JSON.parse(raw);
142
+ const vs = info?.streams?.[0];
143
+ if (!vs)
144
+ return null;
145
+ const codec = (vs.codec_name || "").toLowerCase();
146
+ const tag = (vs.codec_tag_string || "").toLowerCase();
147
+ if (["hevc", "h265"].includes(codec) || ["hvc1", "hev1"].includes(tag)) {
148
+ return "HEVC/H.265 is not supported by Chromium headless";
149
+ }
150
+ const pix = (vs.pix_fmt || "").toLowerCase();
151
+ if (pix && pix !== "yuv420p") {
152
+ return `pixel format "${pix}" is not the preferred yuv420p`;
153
+ }
154
+ return null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ };
160
+ const isFfmpegAvailable = () => {
161
+ try {
162
+ execSync("ffmpeg -version", { stdio: "ignore" });
163
+ return true;
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ };
169
+ const getAudioCodec = (filePath) => {
170
+ try {
171
+ const result = execSync(`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "${filePath}"`, { encoding: "utf8" });
172
+ return result.trim();
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ };
178
+ const convertAudioToAac = (inputPath, outputPath) => {
179
+ execSync(`ffmpeg -y -i "${inputPath}" -c:v copy -c:a aac "${outputPath}"`, {
180
+ stdio: "ignore",
181
+ });
182
+ };
183
+ const prepareLocalMediaForVideoExport = async (json) => {
184
+ const clonedJson = JSON.parse(JSON.stringify(json));
185
+ const pointers = collectMediaSourcePointers(clonedJson);
186
+ const rewriteMap = new Map();
187
+ const urlsToDownload = [];
188
+ const videoUrls = new Set();
189
+ for (const p of pointers) {
190
+ const src = p.target[p.key];
191
+ if (isDataOrFileSrc(src))
192
+ continue;
193
+ try {
194
+ const u = new URL(src);
195
+ if (u.protocol !== "http:" && u.protocol !== "https:")
196
+ continue;
197
+ }
198
+ catch (e) {
199
+ continue;
200
+ }
201
+ if (!rewriteMap.has(src)) {
202
+ rewriteMap.set(src, null);
203
+ urlsToDownload.push(src);
204
+ }
205
+ if (p.kind === "video")
206
+ videoUrls.add(src);
207
+ }
208
+ if (urlsToDownload.length === 0) {
209
+ return { json: clonedJson, cleanup: async () => { } };
210
+ }
211
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "polotno-node-assets-"));
212
+ let didCleanup = false;
213
+ const cleanup = async () => {
214
+ if (didCleanup)
215
+ return;
216
+ didCleanup = true;
217
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
218
+ };
219
+ try {
220
+ await Promise.all(urlsToDownload.map(async (src) => {
221
+ const tmpBase = crypto.randomUUID
222
+ ? crypto.randomUUID()
223
+ : crypto.randomBytes(16).toString("hex");
224
+ const tmpPath = path.join(tempDir, tmpBase);
225
+ const contentType = await downloadUrlToFile(src, tmpPath);
226
+ const ext = guessExtension(src, contentType);
227
+ let finalPath = tmpPath + ext;
228
+ await fs.promises.rename(tmpPath, finalPath);
229
+ if (videoUrls.has(src)) {
230
+ const reason = getVideoNormalizationReason(finalPath);
231
+ if (reason) {
232
+ const normalizedPath = finalPath + ".normalized.mp4";
233
+ execSync(`ffmpeg -y -i "${finalPath}" -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "${normalizedPath}"`, { stdio: "ignore" });
234
+ finalPath = normalizedPath;
235
+ }
236
+ }
237
+ rewriteMap.set(src, pathToFileURL(finalPath).toString());
238
+ }));
239
+ for (const p of pointers) {
240
+ const src = p.target[p.key];
241
+ const replacement = rewriteMap.get(src);
242
+ if (replacement) {
243
+ p.target[p.key] = replacement;
244
+ }
245
+ }
246
+ return { json: clonedJson, cleanup };
247
+ }
248
+ catch (e) {
249
+ await cleanup();
250
+ throw e;
251
+ }
252
+ };
253
+ export const createPage = async (browser, url, requestInterceptor, useFontCache = true) => {
254
+ const page = await browser.newPage();
255
+ // Attach request interceptor if provided
256
+ if (useFontCache || typeof requestInterceptor === "function") {
257
+ await page.setRequestInterception(true);
258
+ page.on("request", (request) => {
259
+ if (useFontCache && fontCache.tryRespond(request)) {
260
+ return;
261
+ }
262
+ if (typeof requestInterceptor === "function") {
263
+ requestInterceptor(request);
264
+ }
265
+ else {
266
+ request.continue().catch(() => { });
267
+ }
268
+ });
269
+ if (useFontCache) {
270
+ page.on("response", (response) => fontCache.capture(response));
271
+ }
272
+ }
273
+ // we need to make sure that headless is inside user agent
274
+ // because we use it for key validation check
275
+ // but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
276
+ // so some languages may not work
277
+ const userAgent = await page.evaluate(() => navigator.userAgent);
278
+ if (userAgent.indexOf("Headless") === -1) {
279
+ await page.setUserAgent(userAgent + " Chrome Headless");
280
+ }
281
+ page.on("console", (msg) => {
282
+ msg.args().forEach(async (message) => {
283
+ // skip style information
284
+ if (message.toString().indexOf("margin") >= 0) {
285
+ return;
286
+ }
287
+ const { subtype, description } = message.remoteObject();
288
+ if (subtype === "error") {
289
+ console.error(description);
290
+ return;
291
+ }
292
+ const text = message
293
+ .toString()
294
+ .replace("JSHandle:", "")
295
+ .replace("%c", "");
296
+ console.log(text);
297
+ });
298
+ });
299
+ page.on("pageerror", (msg) => {
300
+ console.error(msg);
301
+ });
302
+ await page.goto(url);
303
+ return page;
304
+ };
305
+ export const run = async (page, func, args) => {
306
+ return await page.evaluate(func, ...args);
307
+ };
308
+ // In-page export recipes. These run inside the browser (via page.evaluate / run),
309
+ // so they may only reference page globals (`store`, `window`) — not module scope.
310
+ // Defining them once lets the instance methods and the module-level page-taking
311
+ // exports share a single recipe per export format.
312
+ const recipeToDataURL = async (json, attrs) => {
313
+ const pixelRatio = attrs.pixelRatio || 1;
314
+ store.loadJSON(json);
315
+ store.setElementsPixelRatio(pixelRatio);
316
+ await store.waitLoading();
317
+ return store.toDataURL({ ...attrs, pixelRatio });
318
+ };
319
+ const recipeToPDFDataURL = async (json, attrs) => {
320
+ store.loadJSON(json);
321
+ // Honor an `onProgress` callback exposed by the instance `run()`. When it is
322
+ // not exposed (e.g. the module-level page-taking export) this is a no-op.
323
+ if (window.onProgress) {
324
+ attrs.onProgress = (progress) => {
325
+ window.onProgress(progress);
326
+ };
327
+ }
328
+ await store.waitLoading();
329
+ return await store.toPDFDataURL(attrs);
330
+ };
331
+ const recipeToBlob = async (json, attrs) => {
332
+ store.loadJSON(json);
333
+ await store.waitLoading();
334
+ return await store.toBlob(attrs);
335
+ };
336
+ const recipeToGIFDataURL = async (json, attrs) => {
337
+ store.loadJSON(json);
338
+ return await store.toGIFDataURL(attrs);
339
+ };
340
+ export const jsonToDataURL = async (page, json, attrs) => {
341
+ return await page.evaluate(recipeToDataURL, json, attrs || {});
342
+ };
343
+ export const jsonToPDFDataURL = async (page, json, attrs) => {
344
+ return page.evaluate(recipeToPDFDataURL, json, attrs || {});
345
+ };
346
+ export const jsonToBlob = async (page, json, attrs) => {
347
+ return page.evaluate(recipeToBlob, json, attrs || {});
348
+ };
349
+ // Render options that map directly to a `window.config.*` setter on the client.
350
+ // `arg: "value"` passes the option value through whenever it is explicitly set
351
+ // (including `false`, so a client-side default can be turned off). `arg: true`
352
+ // is an enable-only flag: the setter is called with `true` only when the option
353
+ // is truthy (passing `false` is a no-op, leaving the client default in place).
354
+ const CONFIG_OPTION_SETTERS = [
355
+ { option: "assetLoadTimeout", method: "setAssetLoadTimeout", arg: "value" },
356
+ { option: "fontLoadTimeout", method: "setFontLoadTimeout", arg: "value" },
357
+ { option: "legacyRichTextEnabled", method: "setLegacyRichTextEnabled", arg: true },
358
+ { option: "textVerticalResizeEnabled", method: "setTextVerticalResizeEnabled", arg: "value" },
359
+ { option: "textSplitAllowed", method: "unstable_setTextSplitAllowed", arg: true },
360
+ { option: "textOverflow", method: "setTextOverflow", arg: "value" },
361
+ ];
362
+ // Apply the option→config setters above to a page in a single round-trip.
363
+ const applyConfigOptions = async (page, attrs = {}) => {
364
+ const calls = [];
365
+ for (const { option, method, arg } of CONFIG_OPTION_SETTERS) {
366
+ if (arg === "value") {
367
+ // value passthrough — apply on any explicit value, including `false`
368
+ if (attrs[option] !== undefined)
369
+ calls.push({ method, value: attrs[option] });
370
+ }
371
+ else if (attrs[option]) {
372
+ // enable-only flag
373
+ calls.push({ method, value: arg });
374
+ }
375
+ }
376
+ if (calls.length === 0)
377
+ return;
378
+ await page.evaluate((calls) => {
379
+ for (const { method, value } of calls) {
380
+ if (window.config && window.config[method]) {
381
+ window.config[method](value);
382
+ }
383
+ else {
384
+ console.error(`${method} function is not defined in the client.`);
385
+ }
386
+ }
387
+ }, calls);
388
+ };
389
+ const busyPages = [];
390
+ const createdInstances = [];
391
+ export const createInstance = async ({ key, url, useParallelPages = true, browser, requestInterceptor, useFontCache = true, } = {}) => {
392
+ const visitPage = url || `${DEFAULT_CLIENT}?key=${key}`;
393
+ const hasCustomClientUrl = Boolean(url);
394
+ const firstPage = useParallelPages
395
+ ? null
396
+ : await createPage(browser, visitPage, requestInterceptor, useFontCache);
397
+ async function ensureAssetErrorHelpers(page) {
398
+ await page.evaluate(() => {
399
+ // Define helpers once per page. They unify asset-loading error handling
400
+ // across all exports (dataURL/image/pdf/video).
401
+ if (!window.__polotnoConsumeAssetError) {
402
+ window.__polotnoConsumeAssetError = (attrs) => {
403
+ const error = window._polotnoError;
404
+ if (!error)
405
+ return null;
406
+ // Clear immediately to avoid double-reporting.
407
+ window._polotnoError = null;
408
+ const message = String(error);
409
+ // Cross-package contract: every font-load error from polotno core
410
+ // references the family as `font "<name>"` (e.g. `Timeout for
411
+ // loading font "X"`, `Font "X" not found: ...`). Match that stable
412
+ // marker rather than any one full sentence.
413
+ const isFontError = /font\s+"/i.test(message);
414
+ const skipFontError = isFontError && attrs && attrs.skipFontError;
415
+ const isImageError = message.indexOf("image ") >= 0;
416
+ const skipImageError = isImageError && attrs && attrs.skipImageError;
417
+ if (skipFontError || skipImageError) {
418
+ return null;
419
+ }
420
+ return message;
421
+ };
422
+ }
423
+ if (!window.__polotnoThrowAssetErrorIfAny) {
424
+ window.__polotnoThrowAssetErrorIfAny = (attrs) => {
425
+ const message = window.__polotnoConsumeAssetError(attrs);
426
+ if (message) {
427
+ throw new Error(message);
428
+ }
429
+ };
430
+ }
431
+ });
432
+ }
433
+ async function consumeAssetLoadingErrorMessage(page, attrs) {
434
+ try {
435
+ return await page.evaluate((attrs) => {
436
+ if (window.__polotnoConsumeAssetError) {
437
+ return window.__polotnoConsumeAssetError(attrs);
438
+ }
439
+ return null;
440
+ }, attrs);
441
+ }
442
+ catch (e) {
443
+ // If the page is already crashed/unavailable, we can't inspect the error.
444
+ return null;
445
+ }
446
+ }
447
+ const run = async (func, ...args) => {
448
+ const page = useParallelPages
449
+ ? await createPage(browser, visitPage, requestInterceptor, useFontCache)
450
+ : firstPage;
451
+ if (busyPages.indexOf(page) >= 0) {
452
+ throw new Error("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.");
453
+ }
454
+ busyPages.push(page);
455
+ // Setup profiling if profilePath is provided
456
+ let cdpSession = null;
457
+ const profilePath = args[1]?.profilePath;
458
+ if (profilePath) {
459
+ cdpSession = await page.target().createCDPSession();
460
+ await cdpSession.send("Profiler.enable");
461
+ await cdpSession.send("Profiler.start");
462
+ }
463
+ try {
464
+ // All direct `window.config.*` setters are applied from one table.
465
+ await applyConfigOptions(page, args[1]);
466
+ if (args[1]?.onProgress) {
467
+ const originalProgress = args[1].onProgress;
468
+ await page.exposeFunction("onProgress", async (progress, frameTime) => {
469
+ originalProgress(progress, frameTime);
470
+ });
471
+ }
472
+ // Allow exposing custom functions (used for chunked data transfer)
473
+ if (args[1]?.exposeFunctions) {
474
+ for (const [name, fn] of Object.entries(args[1].exposeFunctions)) {
475
+ await page.exposeFunction(name, fn);
476
+ }
477
+ }
478
+ await page.evaluate(() => {
479
+ if (window.config?.onLoadError) {
480
+ window.config.onLoadError((error) => {
481
+ window._polotnoError = error;
482
+ });
483
+ }
484
+ else {
485
+ console.error("onLoadError function is not defined in the client. Error handling will not work.");
486
+ }
487
+ });
488
+ await ensureAssetErrorHelpers(page);
489
+ const result = await page.evaluate(func, ...args);
490
+ // Stop profiler and save profile if enabled
491
+ if (cdpSession && profilePath) {
492
+ const { profile } = await cdpSession.send("Profiler.stop");
493
+ fs.writeFileSync(profilePath, JSON.stringify(profile));
494
+ console.log(`CPU profile saved to: ${profilePath}`);
495
+ }
496
+ const assetErrorMessage = await consumeAssetLoadingErrorMessage(page, args[1]);
497
+ if (assetErrorMessage) {
498
+ throw new Error(assetErrorMessage);
499
+ }
500
+ // remove busy page
501
+ busyPages.splice(busyPages.indexOf(page), 1);
502
+ if (useParallelPages) {
503
+ await page.close();
504
+ }
505
+ return result;
506
+ }
507
+ catch (e) {
508
+ // Stop profiler and save profile even on error
509
+ if (cdpSession && profilePath) {
510
+ console.log("saving profile");
511
+ try {
512
+ const { profile } = await cdpSession.send("Profiler.stop");
513
+ fs.writeFileSync(profilePath, JSON.stringify(profile));
514
+ console.log(`CPU profile saved to: ${profilePath}`);
515
+ }
516
+ catch (profileError) {
517
+ console.error("Failed to save profile:", profileError.message);
518
+ }
519
+ }
520
+ // If `page.evaluate()` failed (e.g. thrown during onProgress), still prefer
521
+ // a captured Polotno asset-loading error when available.
522
+ const assetErrorMessage = await consumeAssetLoadingErrorMessage(page, args[1]);
523
+ // remove busy page
524
+ busyPages.splice(busyPages.indexOf(page), 1);
525
+ if (useParallelPages) {
526
+ await page.close();
527
+ }
528
+ if (assetErrorMessage) {
529
+ throw new Error(assetErrorMessage);
530
+ }
531
+ throw e;
532
+ }
533
+ };
534
+ // Pre-flight schema check: catch malformed JSON before launching the browser
535
+ // and throw `Invalid Polotno design: …`. `assertValidDesign` mirrors the core
536
+ // store's tolerance (it coerces `null` to defaults like the store does, rather
537
+ // than rejecting input the store would happily render), so this only throws on
538
+ // genuinely malformed JSON.
539
+ const assertValidJson = (json) => {
540
+ assertValidDesign(json, { mode: "partial" });
541
+ };
542
+ const jsonToDataURL = async (json, attrs) => {
543
+ await assertValidJson(json);
544
+ return await run(recipeToDataURL, json, attrs || {});
545
+ };
546
+ const jsonToGIFDataURL = async (json, attrs) => {
547
+ await assertValidJson(json);
548
+ return await run(recipeToGIFDataURL, json, attrs || {});
549
+ };
550
+ const jsonToGIFBase64 = async (json, attrs) => {
551
+ const url = await jsonToGIFDataURL(json, attrs);
552
+ return url.split("base64,")[1];
553
+ };
554
+ const jsonToImageBase64 = async (json, attrs) => {
555
+ const url = await jsonToDataURL(json, attrs);
556
+ return url.split("base64,")[1];
557
+ };
558
+ const jsonToPDFDataURL = async (json, attrs) => {
559
+ await assertValidJson(json);
560
+ return await run(recipeToPDFDataURL, json, attrs || {});
561
+ };
562
+ const jsonToPDFBase64 = async (json, attrs) => {
563
+ const url = await jsonToPDFDataURL(json, attrs);
564
+ return url.split("base64,")[1];
565
+ };
566
+ const jsonToBlob = async (json, attrs) => {
567
+ const base64 = await jsonToImageBase64(json, attrs);
568
+ const blob = Buffer.from(base64, "base64");
569
+ return blob;
570
+ };
571
+ const jsonToVideoFile = async (json, outputPath, attrs = {}) => {
572
+ await assertValidJson(json);
573
+ const shouldDownloadMedia = !hasCustomClientUrl && !(attrs && attrs.skipDownloads);
574
+ const prepared = shouldDownloadMedia
575
+ ? await prepareLocalMediaForVideoExport(json)
576
+ : { json, cleanup: async () => { } };
577
+ const writeStream = fs.createWriteStream(outputPath);
578
+ try {
579
+ const mimeType = await run(async (json, attrs) => {
580
+ const pixelRatio = attrs.pixelRatio || 1;
581
+ store.loadJSON(json);
582
+ window.__polotnoThrowAssetErrorIfAny(attrs);
583
+ // keep store internals consistent with image/pdf exports
584
+ store.setElementsPixelRatio(pixelRatio);
585
+ // loop through all pages and wait for loading for all layout calculations
586
+ // (this helps stabilize text/layout across multi-page designs)
587
+ for (const page of store.pages) {
588
+ store.selectPage(page.id);
589
+ await store.waitLoading();
590
+ window.__polotnoThrowAssetErrorIfAny(attrs);
591
+ }
592
+ if (store.pages.length > 0) {
593
+ store.selectPage(store.pages[0].id);
594
+ await store.waitLoading();
595
+ window.__polotnoThrowAssetErrorIfAny(attrs);
596
+ }
597
+ // For video export, we always use 'resize' text overflow mode
598
+ // to ensure text fits properly in animations. User preferences are ignored for now.
599
+ window.config.setTextOverflow("resize");
600
+ if (!window.__polotnoLoadVideoExport) {
601
+ throw new Error("Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().");
602
+ }
603
+ const { storeToVideo } = await window.__polotnoLoadVideoExport();
604
+ // Create abort controller to cancel video render on error
605
+ const abortController = new AbortController();
606
+ let capturedError = null;
607
+ // Always have an internal progress callback to check for errors,
608
+ // even if user didn't provide onProgress
609
+ const progressCallback = (progress, frameTime) => {
610
+ // Check for asset-loading errors during render (without throwing)
611
+ const errorMessage = window.__polotnoConsumeAssetError(attrs);
612
+ if (errorMessage) {
613
+ capturedError = errorMessage;
614
+ abortController.abort();
615
+ return;
616
+ }
617
+ // Call user's progress callback if provided
618
+ if (window.onProgress) {
619
+ window.onProgress(progress, frameTime);
620
+ }
621
+ };
622
+ let videoBlob;
623
+ try {
624
+ videoBlob = await storeToVideo({
625
+ store,
626
+ fps: attrs.fps,
627
+ pixelRatio,
628
+ onProgress: progressCallback,
629
+ signal: abortController.signal,
630
+ });
631
+ }
632
+ catch (e) {
633
+ // If we aborted due to asset error, throw that instead
634
+ if (capturedError) {
635
+ throw new Error(capturedError);
636
+ }
637
+ throw e;
638
+ }
639
+ // Final check for any errors that occurred
640
+ if (capturedError) {
641
+ throw new Error(capturedError);
642
+ }
643
+ // Stream blob in chunks via exposed function to avoid large payload issues.
644
+ // CRITICAL (!!!): Chunk size MUST be a multiple of 3 bytes because base64 encodes
645
+ // 3 bytes -> 4 characters. If chunks aren't aligned, each chunk gets padding
646
+ // (= or ==) at the end, and joining them creates invalid base64 and video may corrupt.
647
+ const CHUNK_SIZE = 3 * 1024 * 1024; // 3MB, divisible by 3
648
+ const arrayBuffer = await videoBlob.arrayBuffer();
649
+ const uint8Array = new Uint8Array(arrayBuffer);
650
+ for (let offset = 0; offset < uint8Array.length; offset += CHUNK_SIZE) {
651
+ const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
652
+ const chunk = uint8Array.slice(offset, end);
653
+ let binary = "";
654
+ for (let i = 0; i < chunk.length; i++) {
655
+ binary += String.fromCharCode(chunk[i]);
656
+ }
657
+ await window.__polotnoWriteChunk(btoa(binary));
658
+ }
659
+ return videoBlob.type;
660
+ }, prepared.json, {
661
+ ...attrs,
662
+ exposeFunctions: {
663
+ __polotnoWriteChunk: (base64Chunk) => {
664
+ const buffer = Buffer.from(base64Chunk, "base64");
665
+ writeStream.write(buffer);
666
+ },
667
+ },
668
+ });
669
+ writeStream.end();
670
+ await new Promise((resolve) => writeStream.on("finish", resolve));
671
+ // Convert audio to AAC if needed
672
+ if (isFfmpegAvailable()) {
673
+ const codec = getAudioCodec(outputPath);
674
+ if (codec && codec !== "aac") {
675
+ const tempPath = outputPath + ".tmp.mp4";
676
+ convertAudioToAac(outputPath, tempPath);
677
+ await fs.promises.rename(tempPath, outputPath);
678
+ }
679
+ }
680
+ return mimeType;
681
+ }
682
+ finally {
683
+ await prepared.cleanup();
684
+ }
685
+ };
686
+ const jsonToVideoDataURL = async (json, attrs) => {
687
+ const tempPath = path.join(os.tmpdir(), `polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
688
+ try {
689
+ const mimeType = await jsonToVideoFile(json, tempPath, attrs);
690
+ const buffer = await fs.promises.readFile(tempPath);
691
+ return `data:${mimeType || "video/mp4"};base64,${buffer.toString("base64")}`;
692
+ }
693
+ finally {
694
+ await fs.promises.rm(tempPath, { force: true });
695
+ }
696
+ };
697
+ const jsonToVideoBase64 = async (json, attrs) => {
698
+ const tempPath = path.join(os.tmpdir(), `polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
699
+ try {
700
+ await jsonToVideoFile(json, tempPath, attrs);
701
+ const buffer = await fs.promises.readFile(tempPath);
702
+ return buffer.toString("base64");
703
+ }
704
+ finally {
705
+ await fs.promises.rm(tempPath, { force: true });
706
+ }
707
+ };
708
+ const instance = {
709
+ close: async () => {
710
+ createdInstances.splice(createdInstances.indexOf(instance), 1);
711
+ await browser.close();
712
+ },
713
+ firstPage,
714
+ browser,
715
+ run,
716
+ jsonToDataURL,
717
+ jsonToImageBase64,
718
+ jsonToPDFDataURL,
719
+ jsonToPDFBase64,
720
+ jsonToBlob,
721
+ jsonToGIFDataURL,
722
+ jsonToGIFBase64,
723
+ jsonToVideoDataURL,
724
+ jsonToVideoBase64,
725
+ jsonToVideoFile,
726
+ createPage: async () => await createPage(browser, visitPage, requestInterceptor, useFontCache),
727
+ };
728
+ createdInstances.push(instance);
729
+ return instance;
730
+ };
731
+ // Function to close all created instances in parallel
732
+ const closeAllInstances = async () => {
733
+ await Promise.all(createdInstances.map((instance) => instance.close()));
734
+ };
735
+ // Also handle other termination signals
736
+ ["SIGINT", "SIGTERM", "SIGQUIT", "exit"].forEach((signal) => {
737
+ process.on(signal, () => {
738
+ closeAllInstances();
739
+ });
740
+ });