polotno-node 3.8.1 → 3.9.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 +79 -0
- package/dist/assets/{index-Bc1WTi4k.js → index-DMNDOkTi.js} +117 -117
- package/dist/assets/{index.es-Dg88tupA.js → index.es-BGauYEGb.js} +1 -1
- package/dist/assets/{jspdf.es.min-CTt99xrZ.js → jspdf.es.min-7Xedaaef.js} +3 -3
- package/dist/assets/lib-B5Aq9qTO.js +1 -0
- package/dist/index.html +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/instance.d.ts +12 -9
- package/lib/instance.js +363 -190
- package/lib/types.d.ts +23 -1
- package/lib/video-status.d.ts +7 -0
- package/lib/video-status.js +107 -0
- package/package.json +5 -5
- package/dist/assets/lib-C3mnf1jX.js +0 -1
package/lib/instance.js
CHANGED
|
@@ -11,6 +11,7 @@ import * as fontCache from './font-cache.js';
|
|
|
11
11
|
import * as localMedia from './local-media.js';
|
|
12
12
|
import { polotnoError, rehydrateError } from './errors.js';
|
|
13
13
|
import { assertUrlAllowed } from './ssrf-guard.js';
|
|
14
|
+
import { createVideoStatusMonitor } from './video-status.js';
|
|
14
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
// Compiled to lib/instance.js; the client bundle is published to ../dist.
|
|
16
17
|
const DEFAULT_CLIENT = `file:${path.join(__dirname, '..', 'dist', 'index.html')}`;
|
|
@@ -337,7 +338,7 @@ const getAudioCodec = (filePath) => {
|
|
|
337
338
|
const convertAudioToAac = (inputPath, outputPath) => {
|
|
338
339
|
execFileSync('ffmpeg', ['-y', '-i', inputPath, '-c:v', 'copy', '-c:a', 'aac', outputPath], { stdio: 'ignore' });
|
|
339
340
|
};
|
|
340
|
-
export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
|
|
341
|
+
export const prepareLocalMediaForVideoExport = async (json, policy = {}, onProgress) => {
|
|
341
342
|
const clonedJson = JSON.parse(JSON.stringify(json));
|
|
342
343
|
const pointers = collectMediaSourcePointers(clonedJson);
|
|
343
344
|
const rewriteMap = new Map();
|
|
@@ -362,6 +363,8 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
|
|
|
362
363
|
if (p.kind === 'video')
|
|
363
364
|
videoUrls.add(src);
|
|
364
365
|
}
|
|
366
|
+
let completed = 0;
|
|
367
|
+
onProgress?.(completed, urlsToDownload.length);
|
|
365
368
|
if (urlsToDownload.length === 0) {
|
|
366
369
|
return { json: clonedJson, cleanup: async () => { } };
|
|
367
370
|
}
|
|
@@ -406,6 +409,7 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
|
|
|
406
409
|
// read — the same path a caller's own local file takes. One place makes
|
|
407
410
|
// that decision, and the extension we just derived carries the type.
|
|
408
411
|
rewriteMap.set(src, pathToFileURL(finalPath).toString());
|
|
412
|
+
onProgress?.(++completed, urlsToDownload.length);
|
|
409
413
|
}));
|
|
410
414
|
for (const p of pointers) {
|
|
411
415
|
const src = p.target[p.key];
|
|
@@ -417,7 +421,9 @@ export const prepareLocalMediaForVideoExport = async (json, policy = {}) => {
|
|
|
417
421
|
return { json: clonedJson, cleanup };
|
|
418
422
|
}
|
|
419
423
|
catch (e) {
|
|
420
|
-
await cleanup()
|
|
424
|
+
await cleanup().catch((error) => {
|
|
425
|
+
console.error('Failed to remove temporary media:', error);
|
|
426
|
+
});
|
|
421
427
|
throw e;
|
|
422
428
|
}
|
|
423
429
|
};
|
|
@@ -450,78 +456,135 @@ const ensureServing = async (page) => {
|
|
|
450
456
|
// runs in the same microtask the await resolves on.
|
|
451
457
|
intercepting.add(page);
|
|
452
458
|
};
|
|
459
|
+
// Match names across caller-supplied Puppeteer copies, not class identity.
|
|
460
|
+
const isProtocolFailure = (error) => error instanceof Error &&
|
|
461
|
+
['ProtocolError', 'TargetCloseError', 'ConnectionClosedError'].includes(error.name);
|
|
462
|
+
// Puppeteer emits a page error on renderer crash, but leaves pending CDP
|
|
463
|
+
// evaluations waiting for protocolTimeout. Heartbeat silence is not this signal:
|
|
464
|
+
// healthy synchronous JavaScript can also delay progress.
|
|
465
|
+
const watchRenderer = (page) => {
|
|
466
|
+
let crashError;
|
|
467
|
+
let reportCrash;
|
|
468
|
+
const crashed = new Promise((resolve) => {
|
|
469
|
+
reportCrash = resolve;
|
|
470
|
+
});
|
|
471
|
+
const onCrash = (error) => {
|
|
472
|
+
crashError = error;
|
|
473
|
+
reportCrash(error);
|
|
474
|
+
};
|
|
475
|
+
page.on('error', onCrash);
|
|
476
|
+
return {
|
|
477
|
+
get failed() {
|
|
478
|
+
return Boolean(crashError);
|
|
479
|
+
},
|
|
480
|
+
async run(operation) {
|
|
481
|
+
if (crashError)
|
|
482
|
+
throw crashError;
|
|
483
|
+
return Promise.race([
|
|
484
|
+
operation(),
|
|
485
|
+
crashed.then((error) => {
|
|
486
|
+
throw error;
|
|
487
|
+
}),
|
|
488
|
+
]);
|
|
489
|
+
},
|
|
490
|
+
dispose() {
|
|
491
|
+
page.off('error', onCrash);
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
// The failed call owns its page, never the browser. Cleanup must neither
|
|
496
|
+
// replace the primary error nor add another protocol timeout before rejection.
|
|
497
|
+
const closeFailedPage = (page) => {
|
|
498
|
+
if (page.isClosed() || !page.browser().connected)
|
|
499
|
+
return;
|
|
500
|
+
void Promise.resolve()
|
|
501
|
+
.then(() => page.close())
|
|
502
|
+
.catch((error) => {
|
|
503
|
+
console.error('Failed to close export page:', error);
|
|
504
|
+
});
|
|
505
|
+
};
|
|
453
506
|
export const createPage = async (browser, url, requestInterceptor, useFontCache = true, navigationTimeout = DEFAULT_NAVIGATION_TIMEOUT) => {
|
|
454
507
|
const page = await browser.newPage();
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
508
|
+
const renderer = watchRenderer(page);
|
|
509
|
+
try {
|
|
510
|
+
ourPages.add(page);
|
|
511
|
+
// `goto` was raised to 120s because 30s could not load this client bundle on
|
|
512
|
+
// a loaded box. Every OTHER wait on the page was left at puppeteer's same 30s
|
|
513
|
+
// default — acquiring an execution context for `evaluate`, waiting for a
|
|
514
|
+
// target — and those wait on that same bundle, in the same conditions. One
|
|
515
|
+
// budget raised and its siblings left behind is the same defect in a
|
|
516
|
+
// different place, so give them the one budget.
|
|
517
|
+
//
|
|
518
|
+
// Not observed failing: three forced whole-repo runs and six at 74 concurrent
|
|
519
|
+
// instances (load 25 on 16 cores) stayed green. This closes the gap the
|
|
520
|
+
// reasoning above implies, rather than a failure anyone has seen.
|
|
521
|
+
//
|
|
522
|
+
// 0 passes through as puppeteer's "no timeout", exactly as it does for
|
|
523
|
+
// `goto`: a caller who disabled the budget meant it.
|
|
524
|
+
page.setDefaultTimeout(navigationTimeout);
|
|
525
|
+
// One handler per page, always attached, because `ensureServing` may turn
|
|
526
|
+
// interception on later. It stays inert until then.
|
|
527
|
+
page.on('request', (request) => {
|
|
528
|
+
if (!intercepting.has(page))
|
|
529
|
+
return;
|
|
530
|
+
if (localMedia.tryRespond(request)) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (useFontCache && fontCache.tryRespond(request)) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (typeof requestInterceptor === 'function') {
|
|
537
|
+
requestInterceptor(request);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
request.continue().catch(() => { });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
if (useFontCache) {
|
|
544
|
+
page.on('response', (response) => fontCache.capture(response));
|
|
480
545
|
}
|
|
481
|
-
if (typeof requestInterceptor === 'function') {
|
|
482
|
-
|
|
546
|
+
if (useFontCache || typeof requestInterceptor === 'function') {
|
|
547
|
+
await renderer.run(() => ensureServing(page));
|
|
483
548
|
}
|
|
484
|
-
|
|
485
|
-
|
|
549
|
+
// we need to make sure that headless is inside user agent
|
|
550
|
+
// because we use it for key validation check
|
|
551
|
+
// but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
|
|
552
|
+
// so some languages may not work
|
|
553
|
+
const userAgent = await renderer.run(() => page.evaluate(() => navigator.userAgent));
|
|
554
|
+
if (userAgent.indexOf('Headless') === -1) {
|
|
555
|
+
await renderer.run(() => page.setUserAgent(userAgent + ' Chrome Headless'));
|
|
486
556
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
557
|
+
page.on('console', (msg) => {
|
|
558
|
+
msg.args().forEach(async (message) => {
|
|
559
|
+
// skip style information
|
|
560
|
+
if (message.toString().indexOf('margin') >= 0) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const { subtype, description } = message.remoteObject();
|
|
564
|
+
if (subtype === 'error') {
|
|
565
|
+
console.error(description);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const text = message
|
|
569
|
+
.toString()
|
|
570
|
+
.replace('JSHandle:', '')
|
|
571
|
+
.replace('%c', '');
|
|
572
|
+
console.log(text);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
page.on('pageerror', (msg) => {
|
|
576
|
+
console.error(msg);
|
|
577
|
+
});
|
|
578
|
+
await renderer.run(() => page.goto(url, { timeout: navigationTimeout }));
|
|
579
|
+
return page;
|
|
490
580
|
}
|
|
491
|
-
|
|
492
|
-
|
|
581
|
+
catch (error) {
|
|
582
|
+
closeFailedPage(page);
|
|
583
|
+
throw error;
|
|
493
584
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
// but also lets NOT just overwrite default one, because Google Fonts may send "reduced" version of font
|
|
497
|
-
// so some languages may not work
|
|
498
|
-
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
499
|
-
if (userAgent.indexOf('Headless') === -1) {
|
|
500
|
-
await page.setUserAgent(userAgent + ' Chrome Headless');
|
|
585
|
+
finally {
|
|
586
|
+
renderer.dispose();
|
|
501
587
|
}
|
|
502
|
-
page.on('console', (msg) => {
|
|
503
|
-
msg.args().forEach(async (message) => {
|
|
504
|
-
// skip style information
|
|
505
|
-
if (message.toString().indexOf('margin') >= 0) {
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
const { subtype, description } = message.remoteObject();
|
|
509
|
-
if (subtype === 'error') {
|
|
510
|
-
console.error(description);
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
const text = message
|
|
514
|
-
.toString()
|
|
515
|
-
.replace('JSHandle:', '')
|
|
516
|
-
.replace('%c', '');
|
|
517
|
-
console.log(text);
|
|
518
|
-
});
|
|
519
|
-
});
|
|
520
|
-
page.on('pageerror', (msg) => {
|
|
521
|
-
console.error(msg);
|
|
522
|
-
});
|
|
523
|
-
await page.goto(url, { timeout: navigationTimeout });
|
|
524
|
-
return page;
|
|
525
588
|
};
|
|
526
589
|
/**
|
|
527
590
|
* Evaluate on a page the caller owns, with the design's local files served
|
|
@@ -713,31 +776,36 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
713
776
|
busyPages.push(page);
|
|
714
777
|
// Setup profiling if profilePath is provided
|
|
715
778
|
let cdpSession = null;
|
|
779
|
+
let profilerStopped = false;
|
|
716
780
|
const profilePath = args[1]?.profilePath;
|
|
781
|
+
const renderer = watchRenderer(page);
|
|
782
|
+
const withRenderer = renderer.run;
|
|
783
|
+
let succeeded = false;
|
|
717
784
|
try {
|
|
718
785
|
if (served.serving) {
|
|
719
|
-
await ensureServing(page);
|
|
786
|
+
await withRenderer(() => ensureServing(page));
|
|
720
787
|
}
|
|
721
788
|
if (profilePath) {
|
|
722
|
-
cdpSession = await page.target().createCDPSession();
|
|
723
|
-
await cdpSession.send('Profiler.enable');
|
|
724
|
-
await cdpSession.send('Profiler.start');
|
|
789
|
+
cdpSession = await withRenderer(() => page.target().createCDPSession());
|
|
790
|
+
await withRenderer(() => cdpSession.send('Profiler.enable'));
|
|
791
|
+
await withRenderer(() => cdpSession.send('Profiler.start'));
|
|
725
792
|
}
|
|
726
793
|
// All direct `window.config.*` setters are applied from one table.
|
|
727
|
-
await applyConfigOptions(page, args[1]);
|
|
794
|
+
await withRenderer(() => applyConfigOptions(page, args[1]));
|
|
728
795
|
if (args[1]?.onProgress) {
|
|
729
796
|
const originalProgress = args[1].onProgress;
|
|
730
|
-
await page.exposeFunction('onProgress', async (progress, frameTime) => {
|
|
731
|
-
originalProgress(progress, frameTime);
|
|
732
|
-
});
|
|
797
|
+
await withRenderer(() => page.exposeFunction('onProgress', async (progress, frameTime, status) => {
|
|
798
|
+
originalProgress(progress, frameTime, status);
|
|
799
|
+
}));
|
|
733
800
|
}
|
|
734
801
|
// Allow exposing custom functions (used for chunked data transfer)
|
|
735
802
|
if (args[1]?.exposeFunctions) {
|
|
736
|
-
|
|
737
|
-
|
|
803
|
+
const functions = args[1].exposeFunctions;
|
|
804
|
+
for (const [name, fn] of Object.entries(functions)) {
|
|
805
|
+
await withRenderer(() => page.exposeFunction(name, fn));
|
|
738
806
|
}
|
|
739
807
|
}
|
|
740
|
-
await page.evaluate(() => {
|
|
808
|
+
await withRenderer(() => page.evaluate(() => {
|
|
741
809
|
if (window.config?.onLoadError) {
|
|
742
810
|
window.config.onLoadError((error) => {
|
|
743
811
|
// New clients pass an Error with `code`/`details` — stash a JSON
|
|
@@ -756,53 +824,63 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
756
824
|
else {
|
|
757
825
|
console.error('onLoadError function is not defined in the client. Error handling will not work.');
|
|
758
826
|
}
|
|
759
|
-
});
|
|
760
|
-
await ensureAssetErrorHelpers(page);
|
|
761
|
-
const result = await page.evaluate(func, ...runArgs);
|
|
827
|
+
}));
|
|
828
|
+
await withRenderer(() => ensureAssetErrorHelpers(page));
|
|
829
|
+
const result = await withRenderer(() => page.evaluate(func, ...runArgs));
|
|
762
830
|
// Stop profiler and save profile if enabled
|
|
763
831
|
if (cdpSession && profilePath) {
|
|
764
|
-
|
|
832
|
+
profilerStopped = true;
|
|
833
|
+
const { profile } = await withRenderer(() => cdpSession.send('Profiler.stop'));
|
|
765
834
|
fs.writeFileSync(profilePath, JSON.stringify(profile));
|
|
766
835
|
console.log(`CPU profile saved to: ${profilePath}`);
|
|
767
836
|
}
|
|
768
|
-
const assetError = await consumeAssetLoadingError(page, args[1]);
|
|
837
|
+
const assetError = await withRenderer(() => consumeAssetLoadingError(page, args[1]));
|
|
769
838
|
if (assetError) {
|
|
770
839
|
throw rehydrateError(assetError);
|
|
771
840
|
}
|
|
772
|
-
// remove busy page
|
|
773
|
-
busyPages.splice(busyPages.indexOf(page), 1);
|
|
774
841
|
if (useParallelPages) {
|
|
775
|
-
await page.close();
|
|
842
|
+
await withRenderer(() => page.close());
|
|
776
843
|
}
|
|
844
|
+
succeeded = true;
|
|
777
845
|
return result;
|
|
778
846
|
}
|
|
779
847
|
catch (e) {
|
|
780
|
-
//
|
|
781
|
-
|
|
848
|
+
// Protocol failures (including CDP timeouts) cannot safely ask the same
|
|
849
|
+
// renderer for diagnostics: each request can cost another full export
|
|
850
|
+
// timeout. Use names to also support caller-supplied Puppeteer copies.
|
|
851
|
+
let rendererUnavailable = renderer.failed || isProtocolFailure(e);
|
|
852
|
+
// Application errors can still produce a useful CPU profile.
|
|
853
|
+
if (!rendererUnavailable &&
|
|
854
|
+
cdpSession &&
|
|
855
|
+
profilePath &&
|
|
856
|
+
!profilerStopped) {
|
|
782
857
|
console.log('saving profile');
|
|
783
858
|
try {
|
|
784
|
-
|
|
859
|
+
profilerStopped = true;
|
|
860
|
+
const { profile } = await withRenderer(() => cdpSession.send('Profiler.stop'));
|
|
785
861
|
fs.writeFileSync(profilePath, JSON.stringify(profile));
|
|
786
862
|
console.log(`CPU profile saved to: ${profilePath}`);
|
|
787
863
|
}
|
|
788
864
|
catch (profileError) {
|
|
865
|
+
rendererUnavailable || (rendererUnavailable = renderer.failed || isProtocolFailure(profileError));
|
|
789
866
|
console.error('Failed to save profile:', profileError.message);
|
|
790
867
|
}
|
|
791
868
|
}
|
|
792
869
|
// If `page.evaluate()` failed (e.g. thrown during onProgress), still prefer
|
|
793
870
|
// a captured Polotno asset-loading error when available.
|
|
794
|
-
const assetError =
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
if (useParallelPages) {
|
|
798
|
-
await page.close();
|
|
799
|
-
}
|
|
871
|
+
const assetError = rendererUnavailable || renderer.failed
|
|
872
|
+
? null
|
|
873
|
+
: await withRenderer(() => consumeAssetLoadingError(page, args[1])).catch(() => null);
|
|
800
874
|
if (assetError) {
|
|
801
875
|
throw rehydrateError(assetError);
|
|
802
876
|
}
|
|
803
877
|
throw e;
|
|
804
878
|
}
|
|
805
879
|
finally {
|
|
880
|
+
renderer.dispose();
|
|
881
|
+
busyPages.splice(busyPages.indexOf(page), 1);
|
|
882
|
+
if (!succeeded && useParallelPages)
|
|
883
|
+
closeFailedPage(page);
|
|
806
884
|
served.release();
|
|
807
885
|
}
|
|
808
886
|
};
|
|
@@ -843,114 +921,171 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
843
921
|
const blob = Buffer.from(base64, 'base64');
|
|
844
922
|
return blob;
|
|
845
923
|
};
|
|
846
|
-
const
|
|
847
|
-
await assertValidJson(json);
|
|
924
|
+
const renderVideoFile = async (json, outputPath, attrs, status) => {
|
|
848
925
|
const shouldDownloadMedia = !hasCustomClientUrl && !(attrs && attrs.skipDownloads);
|
|
849
926
|
const prepared = shouldDownloadMedia
|
|
850
927
|
? await prepareLocalMediaForVideoExport(json, {
|
|
851
928
|
blockPrivateNetwork: attrs?.blockPrivateNetwork,
|
|
852
929
|
fetchGuard: attrs?.fetchGuard,
|
|
853
|
-
})
|
|
930
|
+
}, (completed, total) => status.update({ stage: 'prepare-media', completed, total }))
|
|
854
931
|
: { json, cleanup: async () => { } };
|
|
855
|
-
|
|
932
|
+
let outputFile;
|
|
933
|
+
let writeError;
|
|
934
|
+
let failed = false;
|
|
856
935
|
try {
|
|
936
|
+
const file = await fs.promises.open(outputPath, 'w');
|
|
937
|
+
outputFile = file;
|
|
938
|
+
status.update({ stage: 'load-client' });
|
|
857
939
|
const mimeType = await run(async (json, attrs) => {
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
// keep store internals consistent with image/pdf exports
|
|
862
|
-
store.setElementsPixelRatio(pixelRatio);
|
|
863
|
-
// loop through all pages and wait for loading for all layout calculations
|
|
864
|
-
// (this helps stabilize text/layout across multi-page designs)
|
|
865
|
-
for (const page of store.pages) {
|
|
866
|
-
store.selectPage(page.id);
|
|
867
|
-
await store.waitLoading();
|
|
868
|
-
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
869
|
-
}
|
|
870
|
-
if (store.pages.length > 0) {
|
|
871
|
-
store.selectPage(store.pages[0].id);
|
|
872
|
-
await store.waitLoading();
|
|
873
|
-
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
874
|
-
}
|
|
875
|
-
// For video export, we always use 'resize' text overflow mode
|
|
876
|
-
// to ensure text fits properly in animations. User preferences are ignored for now.
|
|
877
|
-
window.config.setTextOverflow('resize');
|
|
878
|
-
if (!window.__polotnoLoadVideoExport) {
|
|
879
|
-
throw new Error('Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().');
|
|
880
|
-
}
|
|
881
|
-
const { storeToVideo } = await window.__polotnoLoadVideoExport();
|
|
882
|
-
// Create abort controller to cancel video render on error
|
|
883
|
-
const abortController = new AbortController();
|
|
884
|
-
let capturedError = null;
|
|
885
|
-
// Always have an internal progress callback to check for errors,
|
|
886
|
-
// even if user didn't provide onProgress
|
|
887
|
-
const progressCallback = (progress, frameTime) => {
|
|
888
|
-
// Check for asset-loading errors during render (without throwing)
|
|
889
|
-
const assetError = window.__polotnoConsumeAssetError(attrs);
|
|
890
|
-
if (assetError) {
|
|
891
|
-
capturedError = assetError;
|
|
892
|
-
abortController.abort();
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
// Call user's progress callback if provided
|
|
896
|
-
if (window.onProgress) {
|
|
897
|
-
window.onProgress(progress, frameTime);
|
|
940
|
+
const reportStatus = (progress) => {
|
|
941
|
+
if (attrs.reportProgress) {
|
|
942
|
+
void window.onProgress(0, undefined, progress).catch(() => { });
|
|
898
943
|
}
|
|
899
944
|
};
|
|
900
|
-
let
|
|
945
|
+
let heartbeat;
|
|
946
|
+
if (attrs.reportProgress) {
|
|
947
|
+
const beat = () => {
|
|
948
|
+
void window.__polotnoVideoHeartbeat().catch(() => { });
|
|
949
|
+
};
|
|
950
|
+
beat();
|
|
951
|
+
heartbeat = setInterval(beat, 1000);
|
|
952
|
+
}
|
|
901
953
|
try {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
signal: abortController.signal,
|
|
954
|
+
const pixelRatio = attrs.pixelRatio || 1;
|
|
955
|
+
reportStatus({
|
|
956
|
+
stage: 'load-design',
|
|
957
|
+
completed: 0,
|
|
958
|
+
total: json.pages?.length ?? 0,
|
|
908
959
|
});
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
//
|
|
912
|
-
|
|
913
|
-
//
|
|
960
|
+
store.loadJSON(json);
|
|
961
|
+
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
962
|
+
// keep store internals consistent with image/pdf exports
|
|
963
|
+
store.setElementsPixelRatio(pixelRatio);
|
|
964
|
+
// loop through all pages and wait for loading for all layout calculations
|
|
965
|
+
// (this helps stabilize text/layout across multi-page designs)
|
|
966
|
+
let loadedPages = 0;
|
|
967
|
+
for (const page of store.pages) {
|
|
968
|
+
store.selectPage(page.id);
|
|
969
|
+
await store.waitLoading();
|
|
970
|
+
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
971
|
+
reportStatus({
|
|
972
|
+
stage: 'load-design',
|
|
973
|
+
completed: ++loadedPages,
|
|
974
|
+
total: store.pages.length,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
if (store.pages.length > 0) {
|
|
978
|
+
store.selectPage(store.pages[0].id);
|
|
979
|
+
await store.waitLoading();
|
|
980
|
+
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
981
|
+
}
|
|
982
|
+
// For video export, we always use 'resize' text overflow mode
|
|
983
|
+
// to ensure text fits properly in animations. User preferences are ignored for now.
|
|
984
|
+
window.config.setTextOverflow('resize');
|
|
985
|
+
if (!window.__polotnoLoadVideoExport) {
|
|
986
|
+
throw new Error('Video export module loader is not defined in the client. Expected window.__polotnoLoadVideoExport().');
|
|
987
|
+
}
|
|
988
|
+
const { storeToVideo } = await window.__polotnoLoadVideoExport();
|
|
989
|
+
// Create abort controller to cancel video render on error
|
|
990
|
+
const abortController = new AbortController();
|
|
991
|
+
let capturedError = null;
|
|
992
|
+
// Always have an internal progress callback to check for errors,
|
|
993
|
+
// even if user didn't provide onProgress
|
|
994
|
+
const progressCallback = (progress, frameTime, details) => {
|
|
995
|
+
// Check for asset-loading errors during render (without throwing)
|
|
996
|
+
const assetError = window.__polotnoConsumeAssetError(attrs);
|
|
997
|
+
if (assetError) {
|
|
998
|
+
capturedError = assetError;
|
|
999
|
+
abortController.abort();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
// Call user's progress callback if provided
|
|
1003
|
+
if (attrs.reportProgress) {
|
|
1004
|
+
void window
|
|
1005
|
+
.onProgress(progress, frameTime, details)
|
|
1006
|
+
.catch(() => { });
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
reportStatus({ stage: 'setup' });
|
|
1010
|
+
let videoBlob;
|
|
1011
|
+
try {
|
|
1012
|
+
videoBlob = await storeToVideo({
|
|
1013
|
+
store,
|
|
1014
|
+
fps: attrs.fps,
|
|
1015
|
+
pixelRatio,
|
|
1016
|
+
onProgress: progressCallback,
|
|
1017
|
+
signal: abortController.signal,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
catch (e) {
|
|
1021
|
+
// If we aborted due to asset error, throw that instead. Re-stash
|
|
1022
|
+
// it and let the shared helper own the flatten/re-stash protocol
|
|
1023
|
+
// (capturedError already passed the skip flags in consume).
|
|
1024
|
+
if (capturedError) {
|
|
1025
|
+
window._polotnoError = capturedError;
|
|
1026
|
+
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
1027
|
+
}
|
|
1028
|
+
throw e;
|
|
1029
|
+
}
|
|
1030
|
+
// Final check for any errors that occurred
|
|
914
1031
|
if (capturedError) {
|
|
915
1032
|
window._polotnoError = capturedError;
|
|
916
1033
|
window.__polotnoThrowAssetErrorIfAny(attrs);
|
|
917
1034
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1035
|
+
// Transfer bounded chunks; Node decodes each one independently.
|
|
1036
|
+
const CHUNK_SIZE = 3 * 1024 * 1024;
|
|
1037
|
+
reportStatus({
|
|
1038
|
+
stage: 'transfer',
|
|
1039
|
+
completed: 0,
|
|
1040
|
+
total: videoBlob.size,
|
|
1041
|
+
});
|
|
1042
|
+
const arrayBuffer = await videoBlob.arrayBuffer();
|
|
1043
|
+
const uint8Array = new Uint8Array(arrayBuffer);
|
|
1044
|
+
for (let offset = 0; offset < uint8Array.length; offset += CHUNK_SIZE) {
|
|
1045
|
+
const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
|
|
1046
|
+
const chunk = uint8Array.slice(offset, end);
|
|
1047
|
+
let binary = '';
|
|
1048
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
1049
|
+
binary += String.fromCharCode(chunk[i]);
|
|
1050
|
+
}
|
|
1051
|
+
await window.__polotnoWriteChunk(btoa(binary));
|
|
1052
|
+
reportStatus({
|
|
1053
|
+
stage: 'transfer',
|
|
1054
|
+
completed: end,
|
|
1055
|
+
total: uint8Array.length,
|
|
1056
|
+
});
|
|
938
1057
|
}
|
|
939
|
-
|
|
1058
|
+
return videoBlob.type;
|
|
1059
|
+
}
|
|
1060
|
+
finally {
|
|
1061
|
+
clearInterval(heartbeat);
|
|
940
1062
|
}
|
|
941
|
-
return videoBlob.type;
|
|
942
1063
|
}, prepared.json, {
|
|
943
1064
|
...attrs,
|
|
1065
|
+
reportProgress: Boolean(attrs.onProgress),
|
|
1066
|
+
onProgress: attrs.onProgress ? status.progress : undefined,
|
|
944
1067
|
exposeFunctions: {
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1068
|
+
...(attrs.onProgress
|
|
1069
|
+
? {
|
|
1070
|
+
__polotnoVideoHeartbeat: status.heartbeat,
|
|
1071
|
+
}
|
|
1072
|
+
: {}),
|
|
1073
|
+
__polotnoWriteChunk: async (base64Chunk) => {
|
|
1074
|
+
try {
|
|
1075
|
+
await file.writeFile(Buffer.from(base64Chunk, 'base64'));
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
// Keep filesystem details that Puppeteer would flatten.
|
|
1079
|
+
writeError = error;
|
|
1080
|
+
throw error;
|
|
1081
|
+
}
|
|
948
1082
|
},
|
|
949
1083
|
},
|
|
950
1084
|
});
|
|
951
|
-
|
|
952
|
-
await
|
|
1085
|
+
outputFile = undefined;
|
|
1086
|
+
await file.close();
|
|
953
1087
|
// Convert audio to AAC if needed
|
|
1088
|
+
status.update({ stage: 'postprocess' });
|
|
954
1089
|
if (isFfmpegAvailable()) {
|
|
955
1090
|
const codec = getAudioCodec(outputPath);
|
|
956
1091
|
if (codec && codec !== 'aac') {
|
|
@@ -961,32 +1096,70 @@ export const createInstance = async ({ key, url, useParallelPages = true, browse
|
|
|
961
1096
|
}
|
|
962
1097
|
return mimeType;
|
|
963
1098
|
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
failed = true;
|
|
1101
|
+
// A close failure must not replace the render or write failure.
|
|
1102
|
+
await outputFile?.close().catch((closeError) => {
|
|
1103
|
+
console.error('Failed to close video output:', closeError);
|
|
1104
|
+
});
|
|
1105
|
+
throw writeError ?? error;
|
|
1106
|
+
}
|
|
964
1107
|
finally {
|
|
965
|
-
|
|
1108
|
+
try {
|
|
1109
|
+
await prepared.cleanup();
|
|
1110
|
+
}
|
|
1111
|
+
catch (error) {
|
|
1112
|
+
if (!failed)
|
|
1113
|
+
throw error;
|
|
1114
|
+
console.error('Failed to remove temporary media:', error);
|
|
1115
|
+
}
|
|
966
1116
|
}
|
|
967
1117
|
};
|
|
968
|
-
const
|
|
969
|
-
|
|
1118
|
+
const withVideoStatus = async (json, attrs, render) => {
|
|
1119
|
+
await assertValidJson(json);
|
|
1120
|
+
const status = createVideoStatusMonitor(attrs);
|
|
970
1121
|
try {
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
return
|
|
1122
|
+
const result = await render(status);
|
|
1123
|
+
status.finish('completed');
|
|
1124
|
+
return result;
|
|
974
1125
|
}
|
|
975
|
-
|
|
976
|
-
|
|
1126
|
+
catch (error) {
|
|
1127
|
+
status.finish('failed');
|
|
1128
|
+
throw error;
|
|
977
1129
|
}
|
|
978
1130
|
};
|
|
979
|
-
const
|
|
1131
|
+
const jsonToVideoFile = (json, outputPath, attrs = {}) => withVideoStatus(json, attrs, (status) => renderVideoFile(json, outputPath, attrs, status));
|
|
1132
|
+
// Both string APIs own a temporary file. Keep monitoring until it is read
|
|
1133
|
+
// and removed, so a read/cleanup failure cannot follow a completed event.
|
|
1134
|
+
const renderVideoString = (json, attrs, dataURL) => withVideoStatus(json, attrs, async (status) => {
|
|
980
1135
|
const tempPath = path.join(os.tmpdir(), `polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
1136
|
+
let failed = false;
|
|
981
1137
|
try {
|
|
982
|
-
await
|
|
1138
|
+
const mimeType = await renderVideoFile(json, tempPath, attrs, status);
|
|
1139
|
+
status.update({ stage: 'read-output' });
|
|
983
1140
|
const buffer = await fs.promises.readFile(tempPath);
|
|
984
|
-
|
|
1141
|
+
const base64 = buffer.toString('base64');
|
|
1142
|
+
return dataURL
|
|
1143
|
+
? `data:${mimeType || 'video/mp4'};base64,${base64}`
|
|
1144
|
+
: base64;
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
failed = true;
|
|
1148
|
+
throw error;
|
|
985
1149
|
}
|
|
986
1150
|
finally {
|
|
987
|
-
|
|
1151
|
+
try {
|
|
1152
|
+
await fs.promises.rm(tempPath, { force: true });
|
|
1153
|
+
}
|
|
1154
|
+
catch (error) {
|
|
1155
|
+
if (!failed)
|
|
1156
|
+
throw error;
|
|
1157
|
+
console.error('Failed to remove temporary video:', error);
|
|
1158
|
+
}
|
|
988
1159
|
}
|
|
989
|
-
};
|
|
1160
|
+
});
|
|
1161
|
+
const jsonToVideoDataURL = (json, attrs = {}) => renderVideoString(json, attrs, true);
|
|
1162
|
+
const jsonToVideoBase64 = (json, attrs = {}) => renderVideoString(json, attrs, false);
|
|
990
1163
|
const instance = {
|
|
991
1164
|
close: async () => {
|
|
992
1165
|
createdInstances.splice(createdInstances.indexOf(instance), 1);
|