@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.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/browser-renderer.ts
2
2
  import { Renderer, Vector2 } from "@twick/core";
3
3
  import defaultProject from "@twick/visualizer/dist/project.js";
4
+ import { hasAudio } from "@twick/media-utils";
4
5
 
5
6
  // src/audio/video-audio-extractor.ts
6
7
  var VideoElementAudioExtractor = class {
@@ -28,40 +29,113 @@ var VideoElementAudioExtractor = class {
28
29
  * Extract audio by playing the video and capturing audio output
29
30
  */
30
31
  async extractAudio(startTime, duration, playbackRate = 1) {
31
- const source = this.audioContext.createMediaElementSource(this.video);
32
- this.destination = this.audioContext.createMediaStreamDestination();
33
- source.connect(this.destination);
32
+ try {
33
+ const source = this.audioContext.createMediaElementSource(this.video);
34
+ this.destination = this.audioContext.createMediaStreamDestination();
35
+ source.connect(this.destination);
36
+ } catch (err) {
37
+ throw new Error("Video has no audio track");
38
+ }
34
39
  this.audioChunks = [];
35
- this.mediaRecorder = new MediaRecorder(this.destination.stream, {
36
- mimeType: "audio/webm"
37
- });
40
+ let mimeType = "audio/webm";
41
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
42
+ mimeType = "";
43
+ }
44
+ try {
45
+ this.mediaRecorder = new MediaRecorder(this.destination.stream, {
46
+ mimeType: mimeType || void 0
47
+ });
48
+ } catch (err) {
49
+ throw new Error(`Failed to create MediaRecorder: ${err}. Video may have no audio track.`);
50
+ }
38
51
  this.mediaRecorder.ondataavailable = (event) => {
39
- if (event.data.size > 0) {
52
+ if (event.data && event.data.size > 0) {
40
53
  this.audioChunks.push(event.data);
41
54
  }
42
55
  };
43
56
  this.video.currentTime = startTime;
44
57
  this.video.playbackRate = playbackRate;
45
- await new Promise((resolve) => {
46
- this.video.addEventListener("seeked", () => resolve(), { once: true });
58
+ await new Promise((resolve, reject) => {
59
+ const seekTimeout = setTimeout(() => {
60
+ reject(new Error("Video seek timeout"));
61
+ }, 5e3);
62
+ this.video.addEventListener("seeked", () => {
63
+ clearTimeout(seekTimeout);
64
+ resolve();
65
+ }, { once: true });
66
+ this.video.addEventListener("error", () => {
67
+ clearTimeout(seekTimeout);
68
+ reject(new Error("Video seek error"));
69
+ }, { once: true });
47
70
  });
48
71
  return new Promise((resolve, reject) => {
49
72
  const recordingTimeout = setTimeout(() => {
50
- reject(new Error("Audio extraction timeout"));
51
- }, (duration / playbackRate + 5) * 1e3);
52
- this.mediaRecorder.start();
53
- this.video.play();
73
+ this.video.pause();
74
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
75
+ this.mediaRecorder.stop();
76
+ }
77
+ reject(new Error("Audio extraction timeout - video may have no audio track"));
78
+ }, (duration / playbackRate + 10) * 1e3);
79
+ let hasData = false;
80
+ const dataCheckInterval = setInterval(() => {
81
+ if (this.audioChunks.length > 0 && this.audioChunks.some((chunk) => chunk.size > 0)) {
82
+ hasData = true;
83
+ }
84
+ }, 1e3);
85
+ this.mediaRecorder.onerror = (event) => {
86
+ clearInterval(dataCheckInterval);
87
+ clearTimeout(recordingTimeout);
88
+ this.video.pause();
89
+ reject(new Error(`MediaRecorder error: ${event}. Video may have no audio track.`));
90
+ };
91
+ try {
92
+ this.mediaRecorder.start(100);
93
+ this.video.play().catch((playErr) => {
94
+ clearInterval(dataCheckInterval);
95
+ clearTimeout(recordingTimeout);
96
+ reject(new Error(`Failed to play video: ${playErr}`));
97
+ });
98
+ } catch (startErr) {
99
+ clearInterval(dataCheckInterval);
100
+ clearTimeout(recordingTimeout);
101
+ reject(new Error(`Failed to start recording: ${startErr}`));
102
+ }
54
103
  setTimeout(async () => {
104
+ clearInterval(dataCheckInterval);
55
105
  clearTimeout(recordingTimeout);
56
106
  this.video.pause();
57
- this.mediaRecorder.stop();
107
+ if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
108
+ this.mediaRecorder.stop();
109
+ }
110
+ const stopTimeout = setTimeout(() => {
111
+ if (this.audioChunks.length === 0 || !hasData) {
112
+ reject(new Error("No audio data captured - video has no audio track"));
113
+ }
114
+ }, 2e3);
58
115
  await new Promise((res) => {
59
- this.mediaRecorder.addEventListener("stop", () => res(), { once: true });
116
+ if (this.mediaRecorder) {
117
+ this.mediaRecorder.addEventListener("stop", () => {
118
+ clearTimeout(stopTimeout);
119
+ res();
120
+ }, { once: true });
121
+ } else {
122
+ clearTimeout(stopTimeout);
123
+ res();
124
+ }
60
125
  });
61
126
  try {
127
+ if (this.audioChunks.length === 0 || !this.audioChunks.some((chunk) => chunk.size > 0)) {
128
+ throw new Error("No audio data captured - video has no audio track");
129
+ }
62
130
  const audioBlob = new Blob(this.audioChunks, { type: "audio/webm" });
131
+ if (audioBlob.size === 0) {
132
+ throw new Error("Audio blob is empty - video has no audio track");
133
+ }
63
134
  const arrayBuffer = await audioBlob.arrayBuffer();
64
135
  const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
136
+ if (audioBuffer.length === 0 || audioBuffer.duration === 0) {
137
+ throw new Error("Audio buffer is empty - video has no audio track");
138
+ }
65
139
  resolve(audioBuffer);
66
140
  } catch (err) {
67
141
  reject(new Error(`Failed to decode recorded audio: ${err}`));
@@ -284,25 +358,46 @@ function getFFmpegBaseURL() {
284
358
  return "/ffmpeg";
285
359
  }
286
360
  async function muxAudioVideo(options) {
361
+ const muxStartTime = Date.now();
287
362
  try {
363
+ console.log("Starting FFmpeg muxing...");
364
+ console.log(` Video blob size: ${options.videoBlob.size} bytes (${(options.videoBlob.size / 1024 / 1024).toFixed(2)} MB)`);
365
+ console.log(` Audio buffer size: ${options.audioBuffer.byteLength} bytes (${(options.audioBuffer.byteLength / 1024 / 1024).toFixed(2)} MB)`);
288
366
  const { FFmpeg } = await import("@ffmpeg/ffmpeg");
289
367
  const { fetchFile } = await import("@ffmpeg/util");
290
368
  const ffmpeg = new FFmpeg();
291
369
  const base = getFFmpegBaseURL();
292
370
  const coreURL = `${base}/ffmpeg-core.js`;
293
371
  const wasmURL = `${base}/ffmpeg-core.wasm`;
372
+ console.log(`Loading FFmpeg from ${base}`);
373
+ const loadStartTime = Date.now();
294
374
  await ffmpeg.load({
295
375
  coreURL,
296
376
  wasmURL
297
377
  });
378
+ const loadDuration = Date.now() - loadStartTime;
379
+ console.log(`FFmpeg loaded successfully in ${loadDuration}ms`);
380
+ console.log("Writing video and audio files...");
381
+ const writeStartTime = Date.now();
298
382
  await ffmpeg.writeFile(
299
383
  "video.mp4",
300
384
  await fetchFile(options.videoBlob)
301
385
  );
386
+ console.log(` Video file written: ${options.videoBlob.size} bytes`);
302
387
  await ffmpeg.writeFile(
303
388
  "audio.wav",
304
389
  new Uint8Array(options.audioBuffer)
305
390
  );
391
+ const writeDuration = Date.now() - writeStartTime;
392
+ console.log(` Audio file written: ${options.audioBuffer.byteLength} bytes`);
393
+ console.log(`Files written successfully in ${writeDuration}ms`);
394
+ console.log("Executing FFmpeg muxing command...");
395
+ const execStartTime = Date.now();
396
+ const ffmpegLogs = [];
397
+ ffmpeg.on("log", ({ message }) => {
398
+ ffmpegLogs.push(message);
399
+ console.log(` [FFmpeg] ${message}`);
400
+ });
306
401
  await ffmpeg.exec([
307
402
  "-i",
308
403
  "video.mp4",
@@ -317,11 +412,34 @@ async function muxAudioVideo(options) {
317
412
  "-shortest",
318
413
  "output.mp4"
319
414
  ]);
415
+ const execDuration = Date.now() - execStartTime;
416
+ console.log(`FFmpeg muxing completed in ${execDuration}ms`);
417
+ const readStartTime = Date.now();
320
418
  const data = await ffmpeg.readFile("output.mp4");
419
+ const readDuration = Date.now() - readStartTime;
420
+ console.log(`Output file read successfully in ${readDuration}ms`);
321
421
  const uint8 = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
322
- return new Blob([uint8], { type: "video/mp4" });
323
- } catch {
324
- return options.videoBlob;
422
+ const result = new Blob([uint8], { type: "video/mp4" });
423
+ const totalDuration = Date.now() - muxStartTime;
424
+ console.log(`Muxing successful: ${result.size} bytes (${(result.size / 1024 / 1024).toFixed(2)} MB) in ${totalDuration}ms`);
425
+ console.log(` Breakdown: load=${loadDuration}ms, write=${writeDuration}ms, exec=${execDuration}ms, read=${readDuration}ms`);
426
+ return result;
427
+ } catch (error) {
428
+ const totalDuration = Date.now() - muxStartTime;
429
+ const errorMsg = error instanceof Error ? error.message : String(error);
430
+ const errorStack = error instanceof Error ? error.stack : void 0;
431
+ console.error("FFmpeg muxing failed:", errorMsg);
432
+ if (errorStack) {
433
+ console.error("Error stack:", errorStack);
434
+ }
435
+ console.error("Error details:", {
436
+ errorType: error instanceof Error ? error.constructor.name : typeof error,
437
+ errorMessage: errorMsg,
438
+ duration: `${totalDuration}ms`,
439
+ videoBlobSize: options.videoBlob.size,
440
+ audioBufferSize: options.audioBuffer.byteLength
441
+ });
442
+ throw error;
325
443
  }
326
444
  }
327
445
 
@@ -407,33 +525,99 @@ var BrowserWasmExporter = class _BrowserWasmExporter {
407
525
  }
408
526
  async generateAudio(assets, startFrame, endFrame) {
409
527
  try {
528
+ console.log(`Generating audio from ${assets.length} frames`);
410
529
  const processor = new BrowserAudioProcessor();
411
530
  const assetPlacements = getAssetPlacement(assets);
412
531
  if (assetPlacements.length === 0) {
532
+ console.log("No asset placements found");
413
533
  return null;
414
534
  }
535
+ console.log(`Processing ${assetPlacements.length} asset placements`);
415
536
  const processedBuffers = [];
416
- for (const asset of assetPlacements) {
537
+ for (let i = 0; i < assetPlacements.length; i++) {
538
+ const asset = assetPlacements[i];
539
+ console.log(`[${i + 1}/${assetPlacements.length}] Processing asset: ${asset.src} (type: ${asset.type}, volume: ${asset.volume}, playbackRate: ${asset.playbackRate})`);
417
540
  if (asset.volume > 0 && asset.playbackRate > 0) {
541
+ const startTime = Date.now();
418
542
  try {
419
- const buffer = await processor.processAudioAsset(
543
+ if (asset.type === "video") {
544
+ console.log(` \u2192 Checking if asset has audio: ${asset.src.substring(0, 50)}...`);
545
+ try {
546
+ const assetHasAudio = await hasAudio(asset.src);
547
+ if (!assetHasAudio) {
548
+ console.log(` \u23ED Skipping asset (no audio detected): ${asset.src.substring(0, 50)}...`);
549
+ continue;
550
+ }
551
+ console.log(` \u2713 Asset has audio, proceeding: ${asset.src.substring(0, 50)}...`);
552
+ } catch (audioCheckError) {
553
+ const errorMsg = audioCheckError instanceof Error ? audioCheckError.message : String(audioCheckError);
554
+ const errorStack = audioCheckError instanceof Error ? audioCheckError.stack : void 0;
555
+ console.warn(` \u26A0 Audio check failed, proceeding anyway: ${asset.src.substring(0, 50)}...`);
556
+ console.warn(` Error: ${errorMsg}`);
557
+ if (errorStack) {
558
+ console.warn(` Stack: ${errorStack}`);
559
+ }
560
+ }
561
+ }
562
+ console.log(` \u2192 Starting processAudioAsset for: ${asset.src}`);
563
+ const processPromise = processor.processAudioAsset(
420
564
  asset,
421
565
  this.settings.fps || 30,
422
566
  endFrame - startFrame
423
567
  );
568
+ const timeoutPromise = new Promise((_, reject) => {
569
+ setTimeout(() => {
570
+ reject(new Error(`Timeout processing audio asset after 20s - video may have no audio track`));
571
+ }, 2e4);
572
+ });
573
+ const buffer = await Promise.race([processPromise, timeoutPromise]);
574
+ const duration = Date.now() - startTime;
575
+ console.log(` \u2713 Successfully processed audio asset in ${duration}ms: ${asset.src.substring(0, 50)}...`);
424
576
  processedBuffers.push(buffer);
425
- } catch {
577
+ } catch (error) {
578
+ const errorMsg = error instanceof Error ? error.message : String(error);
579
+ const errorStack = error instanceof Error ? error.stack : void 0;
580
+ const duration = Date.now() - startTime;
581
+ console.warn(` \u2717 Failed to process audio asset after ${duration}ms: ${asset.src.substring(0, 50)}...`);
582
+ console.warn(` Error: ${errorMsg}`);
583
+ if (errorStack) {
584
+ console.warn(` Stack: ${errorStack}`);
585
+ }
586
+ console.warn(` Asset details: type=${asset.type}, volume=${asset.volume}, playbackRate=${asset.playbackRate}, startFrame=${asset.startInVideo}, endFrame=${asset.endInVideo}`);
426
587
  }
588
+ } else {
589
+ console.log(` \u23ED Skipping asset: volume=${asset.volume}, playbackRate=${asset.playbackRate}`);
427
590
  }
428
591
  }
429
592
  if (processedBuffers.length === 0) {
593
+ console.warn("No audio buffers were successfully processed");
594
+ console.warn(` Total assets attempted: ${assetPlacements.length}`);
595
+ console.warn(` Assets with volume>0 and playbackRate>0: ${assetPlacements.filter((a) => a.volume > 0 && a.playbackRate > 0).length}`);
430
596
  return null;
431
597
  }
598
+ console.log(`Mixing ${processedBuffers.length} audio buffers`);
599
+ const mixStartTime = Date.now();
432
600
  const mixedBuffer = processor.mixAudioBuffers(processedBuffers);
601
+ const mixDuration = Date.now() - mixStartTime;
602
+ console.log(`Audio mixing completed in ${mixDuration}ms`);
603
+ const wavStartTime = Date.now();
433
604
  const wavData = processor.audioBufferToWav(mixedBuffer);
605
+ const wavDuration = Date.now() - wavStartTime;
606
+ console.log(`WAV conversion completed in ${wavDuration}ms`);
607
+ console.log(`Audio generation complete: ${wavData.byteLength} bytes (${(wavData.byteLength / 1024 / 1024).toFixed(2)} MB)`);
434
608
  await processor.close();
435
609
  return wavData;
436
- } catch {
610
+ } catch (error) {
611
+ const errorMsg = error instanceof Error ? error.message : String(error);
612
+ const errorStack = error instanceof Error ? error.stack : void 0;
613
+ console.error("Audio generation error:", errorMsg);
614
+ if (errorStack) {
615
+ console.error("Error stack:", errorStack);
616
+ }
617
+ console.error("Error details:", {
618
+ errorType: error instanceof Error ? error.constructor.name : typeof error,
619
+ errorMessage: errorMsg
620
+ });
437
621
  return null;
438
622
  }
439
623
  }
@@ -521,6 +705,8 @@ var renderTwickVideoInBrowser = async (config) => {
521
705
  }
522
706
  });
523
707
  }
708
+ let hasAnyAudio = false;
709
+ console.log(`Found ${videoElements.length} video element(s) to check for audio`);
524
710
  if (videoElements.length > 0) {
525
711
  for (const videoEl of videoElements) {
526
712
  const src = videoEl.props?.src;
@@ -544,6 +730,23 @@ var renderTwickVideoInBrowser = async (config) => {
544
730
  reject(new Error(`Failed to load video: ${err?.message || "Unknown error"}`));
545
731
  }, { once: true });
546
732
  });
733
+ if (settings.includeAudio) {
734
+ try {
735
+ console.log(`Checking if video has audio: ${src.substring(0, 50)}...`);
736
+ const videoHasAudio = await hasAudio(src);
737
+ console.log(`Audio check result for ${src.substring(0, 50)}...: ${videoHasAudio ? "HAS AUDIO" : "NO AUDIO"}`);
738
+ if (videoHasAudio) {
739
+ hasAnyAudio = true;
740
+ console.log(`\u2713 Video has audio: ${src.substring(0, 50)}...`);
741
+ } else {
742
+ console.log(`\u2717 Video has no audio: ${src.substring(0, 50)}...`);
743
+ }
744
+ } catch (error) {
745
+ console.warn(`Failed to check audio for ${src.substring(0, 50)}...:`, error);
746
+ hasAnyAudio = true;
747
+ console.log(`\u26A0 Assuming video might have audio due to check error`);
748
+ }
749
+ }
547
750
  }
548
751
  }
549
752
  await renderer.playback.recalculate();
@@ -566,28 +769,103 @@ var renderTwickVideoInBrowser = async (config) => {
566
769
  }
567
770
  await exporter.stop();
568
771
  let audioData = null;
569
- if (settings.includeAudio && mediaAssets.length > 0) {
570
- audioData = await exporter.generateAudio(mediaAssets, 0, totalFrames);
772
+ console.log(`Audio detection summary: hasAnyAudio=${hasAnyAudio}, includeAudio=${settings.includeAudio}, mediaAssets=${mediaAssets.length}`);
773
+ if (settings.includeAudio && mediaAssets.length > 0 && hasAnyAudio) {
774
+ console.log("Starting audio processing (audio detected in videos)");
775
+ if (settings.onProgress) {
776
+ settings.onProgress(0.98);
777
+ }
778
+ try {
779
+ console.log("Calling generateAudio...");
780
+ audioData = await exporter.generateAudio(mediaAssets, 0, totalFrames);
781
+ console.log("generateAudio completed");
782
+ if (audioData) {
783
+ console.log(`\u2713 Audio generation successful: ${audioData.byteLength} bytes`);
784
+ } else {
785
+ console.log("\u2717 No audio data generated");
786
+ }
787
+ if (settings.onProgress) {
788
+ settings.onProgress(0.99);
789
+ }
790
+ } catch (audioError) {
791
+ const errorMsg = audioError instanceof Error ? audioError.message : String(audioError);
792
+ const errorStack = audioError instanceof Error ? audioError.stack : void 0;
793
+ console.error("\u2717 Audio generation failed, continuing without audio");
794
+ console.error(` Error: ${errorMsg}`);
795
+ if (errorStack) {
796
+ console.error(` Stack: ${errorStack}`);
797
+ }
798
+ console.error(" Context:", {
799
+ hasAnyAudio,
800
+ includeAudio: settings.includeAudio,
801
+ mediaAssetsCount: mediaAssets.length,
802
+ totalFrames
803
+ });
804
+ audioData = null;
805
+ }
806
+ } else if (settings.includeAudio && mediaAssets.length > 0 && !hasAnyAudio) {
807
+ console.log("\u23ED Skipping audio processing: no audio detected in videos");
808
+ } else {
809
+ console.log(`\u23ED Skipping audio processing: includeAudio=${settings.includeAudio}, mediaAssets=${mediaAssets.length}, hasAnyAudio=${hasAnyAudio}`);
571
810
  }
572
811
  let finalBlob = exporter.getVideoBlob();
573
812
  if (!finalBlob) {
574
813
  throw new Error("Failed to create video blob");
575
814
  }
815
+ if (finalBlob.size === 0) {
816
+ throw new Error("Video blob is empty. Rendering may have failed.");
817
+ }
576
818
  if (audioData && settings.includeAudio) {
819
+ console.log(`Attempting to mux audio (${audioData.byteLength} bytes) with video (${finalBlob.size} bytes)`);
577
820
  try {
578
- finalBlob = await muxAudioVideo({
821
+ const muxedBlob = await muxAudioVideo({
579
822
  videoBlob: finalBlob,
580
823
  audioBuffer: audioData
581
824
  });
582
- } catch {
583
- const audioBlob = new Blob([audioData], { type: "audio/wav" });
584
- const audioUrl = URL.createObjectURL(audioBlob);
585
- const a = document.createElement("a");
586
- a.href = audioUrl;
587
- a.download = "audio.wav";
588
- a.click();
589
- URL.revokeObjectURL(audioUrl);
825
+ if (!muxedBlob || muxedBlob.size === 0) {
826
+ throw new Error("Muxed video blob is empty");
827
+ }
828
+ if (muxedBlob.size === finalBlob.size) {
829
+ console.warn("Muxed blob size unchanged - muxing may have failed silently");
830
+ } else {
831
+ console.log(`Muxing successful: ${finalBlob.size} bytes -> ${muxedBlob.size} bytes`);
832
+ }
833
+ finalBlob = muxedBlob;
834
+ } catch (muxError) {
835
+ const errorMsg = muxError instanceof Error ? muxError.message : String(muxError);
836
+ const errorStack = muxError instanceof Error ? muxError.stack : void 0;
837
+ console.error("Audio muxing failed");
838
+ console.error(` Error: ${errorMsg}`);
839
+ if (errorStack) {
840
+ console.error(` Stack: ${errorStack}`);
841
+ }
842
+ console.error(" Context:", {
843
+ videoBlobSize: finalBlob.size,
844
+ audioDataSize: audioData?.byteLength || 0
845
+ });
846
+ if (settings.downloadAudioSeparately && audioData) {
847
+ const audioBlob = new Blob([audioData], { type: "audio/wav" });
848
+ const audioUrl = URL.createObjectURL(audioBlob);
849
+ const a = document.createElement("a");
850
+ a.href = audioUrl;
851
+ a.download = "audio.wav";
852
+ a.click();
853
+ URL.revokeObjectURL(audioUrl);
854
+ }
855
+ console.warn("Continuing with video without audio due to muxing failure");
856
+ finalBlob = exporter.getVideoBlob();
857
+ if (!finalBlob || finalBlob.size === 0) {
858
+ throw new Error("Video blob is invalid after muxing failure");
859
+ }
590
860
  }
861
+ } else if (settings.includeAudio && !audioData) {
862
+ console.warn("Audio processing was enabled but no audio data was generated");
863
+ }
864
+ if (!finalBlob || finalBlob.size === 0) {
865
+ throw new Error("Final video blob is empty or invalid");
866
+ }
867
+ if (settings.onProgress) {
868
+ settings.onProgress(1);
591
869
  }
592
870
  if (settings.onComplete) {
593
871
  settings.onComplete(finalBlob);