polotno-node 3.8.1 → 3.10.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/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
- ourPages.add(page);
456
- // `goto` was raised to 120s because 30s could not load this client bundle on
457
- // a loaded box. Every OTHER wait on the page was left at puppeteer's same 30s
458
- // default — acquiring an execution context for `evaluate`, waiting for a
459
- // target — and those wait on that same bundle, in the same conditions. One
460
- // budget raised and its siblings left behind is the same defect in a
461
- // different place, so give them the one budget.
462
- //
463
- // Not observed failing: three forced whole-repo runs and six at 74 concurrent
464
- // instances (load 25 on 16 cores) stayed green. This closes the gap the
465
- // reasoning above implies, rather than a failure anyone has seen.
466
- //
467
- // 0 passes through as puppeteer's "no timeout", exactly as it does for
468
- // `goto`: a caller who disabled the budget meant it.
469
- page.setDefaultTimeout(navigationTimeout);
470
- // One handler per page, always attached, because `ensureServing` may turn
471
- // interception on later. It stays inert until then.
472
- page.on('request', (request) => {
473
- if (!intercepting.has(page))
474
- return;
475
- if (localMedia.tryRespond(request)) {
476
- return;
477
- }
478
- if (useFontCache && fontCache.tryRespond(request)) {
479
- return;
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
- requestInterceptor(request);
546
+ if (useFontCache || typeof requestInterceptor === 'function') {
547
+ await renderer.run(() => ensureServing(page));
483
548
  }
484
- else {
485
- request.continue().catch(() => { });
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
- if (useFontCache) {
489
- page.on('response', (response) => fontCache.capture(response));
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
- if (useFontCache || typeof requestInterceptor === 'function') {
492
- await ensureServing(page);
581
+ catch (error) {
582
+ closeFailedPage(page);
583
+ throw error;
493
584
  }
494
- // we need to make sure that headless is inside user agent
495
- // because we use it for key validation check
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
- for (const [name, fn] of Object.entries(args[1].exposeFunctions)) {
737
- await page.exposeFunction(name, fn);
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
- const { profile } = await cdpSession.send('Profiler.stop');
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
- // Stop profiler and save profile even on error
781
- if (cdpSession && profilePath) {
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
- const { profile } = await cdpSession.send('Profiler.stop');
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 = await consumeAssetLoadingError(page, args[1]);
795
- // remove busy page
796
- busyPages.splice(busyPages.indexOf(page), 1);
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 jsonToVideoFile = async (json, outputPath, attrs = {}) => {
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
- const writeStream = fs.createWriteStream(outputPath);
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 pixelRatio = attrs.pixelRatio || 1;
859
- store.loadJSON(json);
860
- window.__polotnoThrowAssetErrorIfAny(attrs);
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 videoBlob;
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
- videoBlob = await storeToVideo({
903
- store,
904
- fps: attrs.fps,
905
- pixelRatio,
906
- onProgress: progressCallback,
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
- catch (e) {
911
- // If we aborted due to asset error, throw that instead. Re-stash
912
- // it and let the shared helper own the flatten/re-stash protocol
913
- // (capturedError already passed the skip flags in consume).
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
- throw e;
919
- }
920
- // Final check for any errors that occurred
921
- if (capturedError) {
922
- window._polotnoError = capturedError;
923
- window.__polotnoThrowAssetErrorIfAny(attrs);
924
- }
925
- // Stream blob in chunks via exposed function to avoid large payload issues.
926
- // CRITICAL (!!!): Chunk size MUST be a multiple of 3 bytes because base64 encodes
927
- // 3 bytes -> 4 characters. If chunks aren't aligned, each chunk gets padding
928
- // (= or ==) at the end, and joining them creates invalid base64 and video may corrupt.
929
- const CHUNK_SIZE = 3 * 1024 * 1024; // 3MB, divisible by 3
930
- const arrayBuffer = await videoBlob.arrayBuffer();
931
- const uint8Array = new Uint8Array(arrayBuffer);
932
- for (let offset = 0; offset < uint8Array.length; offset += CHUNK_SIZE) {
933
- const end = Math.min(offset + CHUNK_SIZE, uint8Array.length);
934
- const chunk = uint8Array.slice(offset, end);
935
- let binary = '';
936
- for (let i = 0; i < chunk.length; i++) {
937
- binary += String.fromCharCode(chunk[i]);
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
- await window.__polotnoWriteChunk(btoa(binary));
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
- __polotnoWriteChunk: (base64Chunk) => {
946
- const buffer = Buffer.from(base64Chunk, 'base64');
947
- writeStream.write(buffer);
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
- writeStream.end();
952
- await new Promise((resolve) => writeStream.on('finish', resolve));
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
- await prepared.cleanup();
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 jsonToVideoDataURL = async (json, attrs) => {
969
- const tempPath = path.join(os.tmpdir(), `polotno-video-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
1118
+ const withVideoStatus = async (json, attrs, render) => {
1119
+ await assertValidJson(json);
1120
+ const status = createVideoStatusMonitor(attrs);
970
1121
  try {
971
- const mimeType = await jsonToVideoFile(json, tempPath, attrs);
972
- const buffer = await fs.promises.readFile(tempPath);
973
- return `data:${mimeType || 'video/mp4'};base64,${buffer.toString('base64')}`;
1122
+ const result = await render(status);
1123
+ status.finish('completed');
1124
+ return result;
974
1125
  }
975
- finally {
976
- await fs.promises.rm(tempPath, { force: true });
1126
+ catch (error) {
1127
+ status.finish('failed');
1128
+ throw error;
977
1129
  }
978
1130
  };
979
- const jsonToVideoBase64 = async (json, attrs) => {
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 jsonToVideoFile(json, tempPath, attrs);
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
- return buffer.toString('base64');
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
- await fs.promises.rm(tempPath, { force: true });
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);