@twick/browser-render 0.15.8 → 0.15.9

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/dist/index.js CHANGED
@@ -40,6 +40,7 @@ module.exports = __toCommonJS(index_exports);
40
40
  // src/browser-renderer.ts
41
41
  var import_core = require("@twick/core");
42
42
  var import_project = __toESM(require("@twick/visualizer/dist/project.js"));
43
+ var import_media_utils = require("@twick/media-utils");
43
44
 
44
45
  // src/audio/video-audio-extractor.ts
45
46
  var VideoElementAudioExtractor = class {
@@ -67,40 +68,113 @@ var VideoElementAudioExtractor = class {
67
68
  * Extract audio by playing the video and capturing audio output
68
69
  */
69
70
  async extractAudio(startTime, duration, playbackRate = 1) {
70
- const source = this.audioContext.createMediaElementSource(this.video);
71
- this.destination = this.audioContext.createMediaStreamDestination();
72
- source.connect(this.destination);
71
+ try {
72
+ const source = this.audioContext.createMediaElementSource(this.video);
73
+ this.destination = this.audioContext.createMediaStreamDestination();
74
+ source.connect(this.destination);
75
+ } catch (err) {
76
+ throw new Error("Video has no audio track");
77
+ }
73
78
  this.audioChunks = [];
74
- this.mediaRecorder = new MediaRecorder(this.destination.stream, {
75
- mimeType: "audio/webm"
76
- });
79
+ let mimeType = "audio/webm";
80
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
81
+ mimeType = "";
82
+ }
83
+ try {
84
+ this.mediaRecorder = new MediaRecorder(this.destination.stream, {
85
+ mimeType: mimeType || void 0
86
+ });
87
+ } catch (err) {
88
+ throw new Error(`Failed to create MediaRecorder: ${err}. Video may have no audio track.`);
89
+ }
77
90
  this.mediaRecorder.ondataavailable = (event) => {
78
- if (event.data.size > 0) {
91
+ if (event.data && event.data.size > 0) {
79
92
  this.audioChunks.push(event.data);
80
93
  }
81
94
  };
82
95
  this.video.currentTime = startTime;
83
96
  this.video.playbackRate = playbackRate;
84
- await new Promise((resolve) => {
85
- this.video.addEventListener("seeked", () => resolve(), { once: true });
97
+ await new Promise((resolve, reject) => {
98
+ const seekTimeout = setTimeout(() => {
99
+ reject(new Error("Video seek timeout"));
100
+ }, 5e3);
101
+ this.video.addEventListener("seeked", () => {
102
+ clearTimeout(seekTimeout);
103
+ resolve();
104
+ }, { once: true });
105
+ this.video.addEventListener("error", () => {
106
+ clearTimeout(seekTimeout);
107
+ reject(new Error("Video seek error"));
108
+ }, { once: true });
86
109
  });
87
110
  return new Promise((resolve, reject) => {
88
111
  const recordingTimeout = setTimeout(() => {
89
- reject(new Error("Audio extraction timeout"));
90
- }, (duration / playbackRate + 5) * 1e3);
91
- this.mediaRecorder.start();
92
- this.video.play();
112
+ this.video.pause();
113
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
114
+ this.mediaRecorder.stop();
115
+ }
116
+ reject(new Error("Audio extraction timeout - video may have no audio track"));
117
+ }, (duration / playbackRate + 10) * 1e3);
118
+ let hasData = false;
119
+ const dataCheckInterval = setInterval(() => {
120
+ if (this.audioChunks.length > 0 && this.audioChunks.some((chunk) => chunk.size > 0)) {
121
+ hasData = true;
122
+ }
123
+ }, 1e3);
124
+ this.mediaRecorder.onerror = (event) => {
125
+ clearInterval(dataCheckInterval);
126
+ clearTimeout(recordingTimeout);
127
+ this.video.pause();
128
+ reject(new Error(`MediaRecorder error: ${event}. Video may have no audio track.`));
129
+ };
130
+ try {
131
+ this.mediaRecorder.start(100);
132
+ this.video.play().catch((playErr) => {
133
+ clearInterval(dataCheckInterval);
134
+ clearTimeout(recordingTimeout);
135
+ reject(new Error(`Failed to play video: ${playErr}`));
136
+ });
137
+ } catch (startErr) {
138
+ clearInterval(dataCheckInterval);
139
+ clearTimeout(recordingTimeout);
140
+ reject(new Error(`Failed to start recording: ${startErr}`));
141
+ }
93
142
  setTimeout(async () => {
143
+ clearInterval(dataCheckInterval);
94
144
  clearTimeout(recordingTimeout);
95
145
  this.video.pause();
96
- this.mediaRecorder.stop();
146
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
147
+ this.mediaRecorder.stop();
148
+ }
149
+ const stopTimeout = setTimeout(() => {
150
+ if (this.audioChunks.length === 0 || !hasData) {
151
+ reject(new Error("No audio data captured - video has no audio track"));
152
+ }
153
+ }, 2e3);
97
154
  await new Promise((res) => {
98
- this.mediaRecorder.addEventListener("stop", () => res(), { once: true });
155
+ if (this.mediaRecorder) {
156
+ this.mediaRecorder.addEventListener("stop", () => {
157
+ clearTimeout(stopTimeout);
158
+ res();
159
+ }, { once: true });
160
+ } else {
161
+ clearTimeout(stopTimeout);
162
+ res();
163
+ }
99
164
  });
100
165
  try {
166
+ if (this.audioChunks.length === 0 || !this.audioChunks.some((chunk) => chunk.size > 0)) {
167
+ throw new Error("No audio data captured - video has no audio track");
168
+ }
101
169
  const audioBlob = new Blob(this.audioChunks, { type: "audio/webm" });
170
+ if (audioBlob.size === 0) {
171
+ throw new Error("Audio blob is empty - video has no audio track");
172
+ }
102
173
  const arrayBuffer = await audioBlob.arrayBuffer();
103
174
  const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
175
+ if (audioBuffer.length === 0 || audioBuffer.duration === 0) {
176
+ throw new Error("Audio buffer is empty - video has no audio track");
177
+ }
104
178
  resolve(audioBuffer);
105
179
  } catch (err) {
106
180
  reject(new Error(`Failed to decode recorded audio: ${err}`));
@@ -323,25 +397,46 @@ function getFFmpegBaseURL() {
323
397
  return "/ffmpeg";
324
398
  }
325
399
  async function muxAudioVideo(options) {
400
+ const muxStartTime = Date.now();
326
401
  try {
402
+ console.log("Starting FFmpeg muxing...");
403
+ console.log(` Video blob size: ${options.videoBlob.size} bytes (${(options.videoBlob.size / 1024 / 1024).toFixed(2)} MB)`);
404
+ console.log(` Audio buffer size: ${options.audioBuffer.byteLength} bytes (${(options.audioBuffer.byteLength / 1024 / 1024).toFixed(2)} MB)`);
327
405
  const { FFmpeg } = await import("@ffmpeg/ffmpeg");
328
406
  const { fetchFile } = await import("@ffmpeg/util");
329
407
  const ffmpeg = new FFmpeg();
330
408
  const base = getFFmpegBaseURL();
331
409
  const coreURL = `${base}/ffmpeg-core.js`;
332
410
  const wasmURL = `${base}/ffmpeg-core.wasm`;
411
+ console.log(`Loading FFmpeg from ${base}`);
412
+ const loadStartTime = Date.now();
333
413
  await ffmpeg.load({
334
414
  coreURL,
335
415
  wasmURL
336
416
  });
417
+ const loadDuration = Date.now() - loadStartTime;
418
+ console.log(`FFmpeg loaded successfully in ${loadDuration}ms`);
419
+ console.log("Writing video and audio files...");
420
+ const writeStartTime = Date.now();
337
421
  await ffmpeg.writeFile(
338
422
  "video.mp4",
339
423
  await fetchFile(options.videoBlob)
340
424
  );
425
+ console.log(` Video file written: ${options.videoBlob.size} bytes`);
341
426
  await ffmpeg.writeFile(
342
427
  "audio.wav",
343
428
  new Uint8Array(options.audioBuffer)
344
429
  );
430
+ const writeDuration = Date.now() - writeStartTime;
431
+ console.log(` Audio file written: ${options.audioBuffer.byteLength} bytes`);
432
+ console.log(`Files written successfully in ${writeDuration}ms`);
433
+ console.log("Executing FFmpeg muxing command...");
434
+ const execStartTime = Date.now();
435
+ const ffmpegLogs = [];
436
+ ffmpeg.on("log", ({ message }) => {
437
+ ffmpegLogs.push(message);
438
+ console.log(` [FFmpeg] ${message}`);
439
+ });
345
440
  await ffmpeg.exec([
346
441
  "-i",
347
442
  "video.mp4",
@@ -356,11 +451,34 @@ async function muxAudioVideo(options) {
356
451
  "-shortest",
357
452
  "output.mp4"
358
453
  ]);
454
+ const execDuration = Date.now() - execStartTime;
455
+ console.log(`FFmpeg muxing completed in ${execDuration}ms`);
456
+ const readStartTime = Date.now();
359
457
  const data = await ffmpeg.readFile("output.mp4");
458
+ const readDuration = Date.now() - readStartTime;
459
+ console.log(`Output file read successfully in ${readDuration}ms`);
360
460
  const uint8 = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
361
- return new Blob([uint8], { type: "video/mp4" });
362
- } catch {
363
- return options.videoBlob;
461
+ const result = new Blob([uint8], { type: "video/mp4" });
462
+ const totalDuration = Date.now() - muxStartTime;
463
+ console.log(`Muxing successful: ${result.size} bytes (${(result.size / 1024 / 1024).toFixed(2)} MB) in ${totalDuration}ms`);
464
+ console.log(` Breakdown: load=${loadDuration}ms, write=${writeDuration}ms, exec=${execDuration}ms, read=${readDuration}ms`);
465
+ return result;
466
+ } catch (error) {
467
+ const totalDuration = Date.now() - muxStartTime;
468
+ const errorMsg = error instanceof Error ? error.message : String(error);
469
+ const errorStack = error instanceof Error ? error.stack : void 0;
470
+ console.error("FFmpeg muxing failed:", errorMsg);
471
+ if (errorStack) {
472
+ console.error("Error stack:", errorStack);
473
+ }
474
+ console.error("Error details:", {
475
+ errorType: error instanceof Error ? error.constructor.name : typeof error,
476
+ errorMessage: errorMsg,
477
+ duration: `${totalDuration}ms`,
478
+ videoBlobSize: options.videoBlob.size,
479
+ audioBufferSize: options.audioBuffer.byteLength
480
+ });
481
+ throw error;
364
482
  }
365
483
  }
366
484
 
@@ -446,33 +564,99 @@ var BrowserWasmExporter = class _BrowserWasmExporter {
446
564
  }
447
565
  async generateAudio(assets, startFrame, endFrame) {
448
566
  try {
567
+ console.log(`Generating audio from ${assets.length} frames`);
449
568
  const processor = new BrowserAudioProcessor();
450
569
  const assetPlacements = getAssetPlacement(assets);
451
570
  if (assetPlacements.length === 0) {
571
+ console.log("No asset placements found");
452
572
  return null;
453
573
  }
574
+ console.log(`Processing ${assetPlacements.length} asset placements`);
454
575
  const processedBuffers = [];
455
- for (const asset of assetPlacements) {
576
+ for (let i = 0; i < assetPlacements.length; i++) {
577
+ const asset = assetPlacements[i];
578
+ console.log(`[${i + 1}/${assetPlacements.length}] Processing asset: ${asset.src} (type: ${asset.type}, volume: ${asset.volume}, playbackRate: ${asset.playbackRate})`);
456
579
  if (asset.volume > 0 && asset.playbackRate > 0) {
580
+ const startTime = Date.now();
457
581
  try {
458
- const buffer = await processor.processAudioAsset(
582
+ if (asset.type === "video") {
583
+ console.log(` \u2192 Checking if asset has audio: ${asset.src.substring(0, 50)}...`);
584
+ try {
585
+ const assetHasAudio = await (0, import_media_utils.hasAudio)(asset.src);
586
+ if (!assetHasAudio) {
587
+ console.log(` \u23ED Skipping asset (no audio detected): ${asset.src.substring(0, 50)}...`);
588
+ continue;
589
+ }
590
+ console.log(` \u2713 Asset has audio, proceeding: ${asset.src.substring(0, 50)}...`);
591
+ } catch (audioCheckError) {
592
+ const errorMsg = audioCheckError instanceof Error ? audioCheckError.message : String(audioCheckError);
593
+ const errorStack = audioCheckError instanceof Error ? audioCheckError.stack : void 0;
594
+ console.warn(` \u26A0 Audio check failed, proceeding anyway: ${asset.src.substring(0, 50)}...`);
595
+ console.warn(` Error: ${errorMsg}`);
596
+ if (errorStack) {
597
+ console.warn(` Stack: ${errorStack}`);
598
+ }
599
+ }
600
+ }
601
+ console.log(` \u2192 Starting processAudioAsset for: ${asset.src}`);
602
+ const processPromise = processor.processAudioAsset(
459
603
  asset,
460
604
  this.settings.fps || 30,
461
605
  endFrame - startFrame
462
606
  );
607
+ const timeoutPromise = new Promise((_, reject) => {
608
+ setTimeout(() => {
609
+ reject(new Error(`Timeout processing audio asset after 20s - video may have no audio track`));
610
+ }, 2e4);
611
+ });
612
+ const buffer = await Promise.race([processPromise, timeoutPromise]);
613
+ const duration = Date.now() - startTime;
614
+ console.log(` \u2713 Successfully processed audio asset in ${duration}ms: ${asset.src.substring(0, 50)}...`);
463
615
  processedBuffers.push(buffer);
464
- } catch {
616
+ } catch (error) {
617
+ const errorMsg = error instanceof Error ? error.message : String(error);
618
+ const errorStack = error instanceof Error ? error.stack : void 0;
619
+ const duration = Date.now() - startTime;
620
+ console.warn(` \u2717 Failed to process audio asset after ${duration}ms: ${asset.src.substring(0, 50)}...`);
621
+ console.warn(` Error: ${errorMsg}`);
622
+ if (errorStack) {
623
+ console.warn(` Stack: ${errorStack}`);
624
+ }
625
+ console.warn(` Asset details: type=${asset.type}, volume=${asset.volume}, playbackRate=${asset.playbackRate}, startFrame=${asset.startInVideo}, endFrame=${asset.endInVideo}`);
465
626
  }
627
+ } else {
628
+ console.log(` \u23ED Skipping asset: volume=${asset.volume}, playbackRate=${asset.playbackRate}`);
466
629
  }
467
630
  }
468
631
  if (processedBuffers.length === 0) {
632
+ console.warn("No audio buffers were successfully processed");
633
+ console.warn(` Total assets attempted: ${assetPlacements.length}`);
634
+ console.warn(` Assets with volume>0 and playbackRate>0: ${assetPlacements.filter((a) => a.volume > 0 && a.playbackRate > 0).length}`);
469
635
  return null;
470
636
  }
637
+ console.log(`Mixing ${processedBuffers.length} audio buffers`);
638
+ const mixStartTime = Date.now();
471
639
  const mixedBuffer = processor.mixAudioBuffers(processedBuffers);
640
+ const mixDuration = Date.now() - mixStartTime;
641
+ console.log(`Audio mixing completed in ${mixDuration}ms`);
642
+ const wavStartTime = Date.now();
472
643
  const wavData = processor.audioBufferToWav(mixedBuffer);
644
+ const wavDuration = Date.now() - wavStartTime;
645
+ console.log(`WAV conversion completed in ${wavDuration}ms`);
646
+ console.log(`Audio generation complete: ${wavData.byteLength} bytes (${(wavData.byteLength / 1024 / 1024).toFixed(2)} MB)`);
473
647
  await processor.close();
474
648
  return wavData;
475
- } catch {
649
+ } catch (error) {
650
+ const errorMsg = error instanceof Error ? error.message : String(error);
651
+ const errorStack = error instanceof Error ? error.stack : void 0;
652
+ console.error("Audio generation error:", errorMsg);
653
+ if (errorStack) {
654
+ console.error("Error stack:", errorStack);
655
+ }
656
+ console.error("Error details:", {
657
+ errorType: error instanceof Error ? error.constructor.name : typeof error,
658
+ errorMessage: errorMsg
659
+ });
476
660
  return null;
477
661
  }
478
662
  }
@@ -560,6 +744,8 @@ var renderTwickVideoInBrowser = async (config) => {
560
744
  }
561
745
  });
562
746
  }
747
+ let hasAnyAudio = false;
748
+ console.log(`Found ${videoElements.length} video element(s) to check for audio`);
563
749
  if (videoElements.length > 0) {
564
750
  for (const videoEl of videoElements) {
565
751
  const src = videoEl.props?.src;
@@ -583,6 +769,23 @@ var renderTwickVideoInBrowser = async (config) => {
583
769
  reject(new Error(`Failed to load video: ${err?.message || "Unknown error"}`));
584
770
  }, { once: true });
585
771
  });
772
+ if (settings.includeAudio) {
773
+ try {
774
+ console.log(`Checking if video has audio: ${src.substring(0, 50)}...`);
775
+ const videoHasAudio = await (0, import_media_utils.hasAudio)(src);
776
+ console.log(`Audio check result for ${src.substring(0, 50)}...: ${videoHasAudio ? "HAS AUDIO" : "NO AUDIO"}`);
777
+ if (videoHasAudio) {
778
+ hasAnyAudio = true;
779
+ console.log(`\u2713 Video has audio: ${src.substring(0, 50)}...`);
780
+ } else {
781
+ console.log(`\u2717 Video has no audio: ${src.substring(0, 50)}...`);
782
+ }
783
+ } catch (error) {
784
+ console.warn(`Failed to check audio for ${src.substring(0, 50)}...:`, error);
785
+ hasAnyAudio = true;
786
+ console.log(`\u26A0 Assuming video might have audio due to check error`);
787
+ }
788
+ }
586
789
  }
587
790
  }
588
791
  await renderer.playback.recalculate();
@@ -605,28 +808,103 @@ var renderTwickVideoInBrowser = async (config) => {
605
808
  }
606
809
  await exporter.stop();
607
810
  let audioData = null;
608
- if (settings.includeAudio && mediaAssets.length > 0) {
609
- audioData = await exporter.generateAudio(mediaAssets, 0, totalFrames);
811
+ console.log(`Audio detection summary: hasAnyAudio=${hasAnyAudio}, includeAudio=${settings.includeAudio}, mediaAssets=${mediaAssets.length}`);
812
+ if (settings.includeAudio && mediaAssets.length > 0 && hasAnyAudio) {
813
+ console.log("Starting audio processing (audio detected in videos)");
814
+ if (settings.onProgress) {
815
+ settings.onProgress(0.98);
816
+ }
817
+ try {
818
+ console.log("Calling generateAudio...");
819
+ audioData = await exporter.generateAudio(mediaAssets, 0, totalFrames);
820
+ console.log("generateAudio completed");
821
+ if (audioData) {
822
+ console.log(`\u2713 Audio generation successful: ${audioData.byteLength} bytes`);
823
+ } else {
824
+ console.log("\u2717 No audio data generated");
825
+ }
826
+ if (settings.onProgress) {
827
+ settings.onProgress(0.99);
828
+ }
829
+ } catch (audioError) {
830
+ const errorMsg = audioError instanceof Error ? audioError.message : String(audioError);
831
+ const errorStack = audioError instanceof Error ? audioError.stack : void 0;
832
+ console.error("\u2717 Audio generation failed, continuing without audio");
833
+ console.error(` Error: ${errorMsg}`);
834
+ if (errorStack) {
835
+ console.error(` Stack: ${errorStack}`);
836
+ }
837
+ console.error(" Context:", {
838
+ hasAnyAudio,
839
+ includeAudio: settings.includeAudio,
840
+ mediaAssetsCount: mediaAssets.length,
841
+ totalFrames
842
+ });
843
+ audioData = null;
844
+ }
845
+ } else if (settings.includeAudio && mediaAssets.length > 0 && !hasAnyAudio) {
846
+ console.log("\u23ED Skipping audio processing: no audio detected in videos");
847
+ } else {
848
+ console.log(`\u23ED Skipping audio processing: includeAudio=${settings.includeAudio}, mediaAssets=${mediaAssets.length}, hasAnyAudio=${hasAnyAudio}`);
610
849
  }
611
850
  let finalBlob = exporter.getVideoBlob();
612
851
  if (!finalBlob) {
613
852
  throw new Error("Failed to create video blob");
614
853
  }
854
+ if (finalBlob.size === 0) {
855
+ throw new Error("Video blob is empty. Rendering may have failed.");
856
+ }
615
857
  if (audioData && settings.includeAudio) {
858
+ console.log(`Attempting to mux audio (${audioData.byteLength} bytes) with video (${finalBlob.size} bytes)`);
616
859
  try {
617
- finalBlob = await muxAudioVideo({
860
+ const muxedBlob = await muxAudioVideo({
618
861
  videoBlob: finalBlob,
619
862
  audioBuffer: audioData
620
863
  });
621
- } catch {
622
- const audioBlob = new Blob([audioData], { type: "audio/wav" });
623
- const audioUrl = URL.createObjectURL(audioBlob);
624
- const a = document.createElement("a");
625
- a.href = audioUrl;
626
- a.download = "audio.wav";
627
- a.click();
628
- URL.revokeObjectURL(audioUrl);
864
+ if (!muxedBlob || muxedBlob.size === 0) {
865
+ throw new Error("Muxed video blob is empty");
866
+ }
867
+ if (muxedBlob.size === finalBlob.size) {
868
+ console.warn("Muxed blob size unchanged - muxing may have failed silently");
869
+ } else {
870
+ console.log(`Muxing successful: ${finalBlob.size} bytes -> ${muxedBlob.size} bytes`);
871
+ }
872
+ finalBlob = muxedBlob;
873
+ } catch (muxError) {
874
+ const errorMsg = muxError instanceof Error ? muxError.message : String(muxError);
875
+ const errorStack = muxError instanceof Error ? muxError.stack : void 0;
876
+ console.error("Audio muxing failed");
877
+ console.error(` Error: ${errorMsg}`);
878
+ if (errorStack) {
879
+ console.error(` Stack: ${errorStack}`);
880
+ }
881
+ console.error(" Context:", {
882
+ videoBlobSize: finalBlob.size,
883
+ audioDataSize: audioData?.byteLength || 0
884
+ });
885
+ if (settings.downloadAudioSeparately && audioData) {
886
+ const audioBlob = new Blob([audioData], { type: "audio/wav" });
887
+ const audioUrl = URL.createObjectURL(audioBlob);
888
+ const a = document.createElement("a");
889
+ a.href = audioUrl;
890
+ a.download = "audio.wav";
891
+ a.click();
892
+ URL.revokeObjectURL(audioUrl);
893
+ }
894
+ console.warn("Continuing with video without audio due to muxing failure");
895
+ finalBlob = exporter.getVideoBlob();
896
+ if (!finalBlob || finalBlob.size === 0) {
897
+ throw new Error("Video blob is invalid after muxing failure");
898
+ }
629
899
  }
900
+ } else if (settings.includeAudio && !audioData) {
901
+ console.warn("Audio processing was enabled but no audio data was generated");
902
+ }
903
+ if (!finalBlob || finalBlob.size === 0) {
904
+ throw new Error("Final video blob is empty or invalid");
905
+ }
906
+ if (settings.onProgress) {
907
+ settings.onProgress(1);
630
908
  }
631
909
  if (settings.onComplete) {
632
910
  settings.onComplete(finalBlob);