victor HF Staff commited on
Commit
08e9d45
Β·
1 Parent(s): ade0af5

feat: fast MP4 video generation via WebCodecs + mp4-muxer

Browse files
Files changed (1) hide show
  1. index.html +83 -48
index.html CHANGED
@@ -5,7 +5,8 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>ace-step-jam</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
- <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600&display=swap" rel="stylesheet" />
 
9
  <style>
10
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
 
@@ -437,7 +438,9 @@
437
  // ── Current song state (for video generation) ────────────────────────────
438
  let currentSong = null;
439
 
440
- // ── Video generation ────────────────────────────────────────────────────
 
 
441
  async function generateShareVideo(song, btn) {
442
  const origHTML = btn.innerHTML;
443
  btn.disabled = true;
@@ -461,8 +464,12 @@
461
 
462
  const audioResp = await fetch(song.audio_url);
463
  const audioArrayBuf = await audioResp.arrayBuffer();
464
- const audioCtx = new AudioContext();
465
- const decoded = await audioCtx.decodeAudioData(audioArrayBuf);
 
 
 
 
466
 
467
  const raw = decoded.getChannelData(0);
468
  const numBars = 60;
@@ -486,28 +493,11 @@
486
  const W = 1080, H = 1080;
487
  const canvas = document.createElement("canvas");
488
  canvas.width = W; canvas.height = H;
489
- const ctx = canvas.getContext("2d");
490
-
491
- const source = audioCtx.createBufferSource();
492
- source.buffer = decoded;
493
- const audioDest = audioCtx.createMediaStreamDestination();
494
- source.connect(audioDest);
495
-
496
- const canvasStream = canvas.captureStream(30);
497
- const combined = new MediaStream([
498
- ...canvasStream.getTracks(),
499
- ...audioDest.stream.getTracks(),
500
- ]);
501
-
502
- const recorder = new MediaRecorder(combined, {
503
- mimeType: MediaRecorder.isTypeSupported("video/webm;codecs=vp9,opus")
504
- ? "video/webm;codecs=vp9,opus" : "video/webm",
505
- videoBitsPerSecond: 2_000_000,
506
- });
507
- const chunks = [];
508
- recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
509
 
510
  const duration = decoded.duration;
 
 
511
 
512
  function drawFrame(progress) {
513
  ctx.fillStyle = "#111111";
@@ -563,33 +553,78 @@
563
  ctx.textAlign = "left";
564
  }
565
 
566
- recorder.start(100);
567
- source.start();
568
- const startT = performance.now();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569
 
 
 
570
  await new Promise((resolve) => {
571
- function tick() {
572
- const elapsed = (performance.now() - startT) / 1000;
573
- const progress = Math.min(elapsed / duration, 1);
574
- drawFrame(progress);
575
- pctEl.textContent = `${Math.round(progress * 100)}%`;
576
- barEl.style.width = `${Math.round(progress * 100)}%`;
577
- if (progress < 1) requestAnimationFrame(tick);
578
- else setTimeout(() => recorder.stop(), 200);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
  }
580
- recorder.onstop = () => {
581
- const blob = new Blob(chunks, { type: "video/webm" });
582
- const url = URL.createObjectURL(blob);
583
- const a = document.createElement("a");
584
- a.href = url;
585
- const safeName = (song.title || "song").replace(/[^a-zA-Z0-9 -]/g, "").trim().replace(/\s+/g, "-") || "song";
586
- a.download = `${safeName}.webm`;
587
- a.click();
588
- URL.revokeObjectURL(url);
589
- audioCtx.close();
590
- resolve();
591
- };
592
- requestAnimationFrame(tick);
593
  });
594
  } catch (e) {
595
  console.error("Video generation failed:", e);
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>ace-step-jam</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
9
+ <script src="https://cdn.jsdelivr.net/npm/mp4-muxer@5.1.3/build/mp4-muxer.min.js"></script>
10
  <style>
11
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
12
 
 
438
  // ── Current song state (for video generation) ────────────────────────────
439
  let currentSong = null;
440
 
441
+ // ── Fast video generation (WebCodecs + MP4 muxer) ─────────────────────
442
+ const { Muxer: Mp4Muxer, ArrayBufferTarget } = Mp4Muxer;
443
+
444
  async function generateShareVideo(song, btn) {
445
  const origHTML = btn.innerHTML;
446
  btn.disabled = true;
 
464
 
465
  const audioResp = await fetch(song.audio_url);
466
  const audioArrayBuf = await audioResp.arrayBuffer();
467
+ const offCtx = new OfflineAudioContext(2, 1, 44100);
468
+ const decoded = await offCtx.decodeAudioData(audioArrayBuf);
469
+
470
+ const sampleRate = decoded.sampleRate;
471
+ const numChannels = decoded.numberOfChannels;
472
+ const audioLength = decoded.length;
473
 
474
  const raw = decoded.getChannelData(0);
475
  const numBars = 60;
 
493
  const W = 1080, H = 1080;
494
  const canvas = document.createElement("canvas");
495
  canvas.width = W; canvas.height = H;
496
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
 
498
  const duration = decoded.duration;
499
+ const FPS = 15;
500
+ const totalFrames = Math.ceil(duration * FPS);
501
 
502
  function drawFrame(progress) {
503
  ctx.fillStyle = "#111111";
 
553
  ctx.textAlign = "left";
554
  }
555
 
556
+ // ── Encode with WebCodecs + MP4 muxer ──
557
+ const target = new ArrayBufferTarget();
558
+ const muxer = new Mp4Muxer({
559
+ target,
560
+ video: { codec: "avc", width: W, height: H },
561
+ audio: { codec: "aac", sampleRate, numberOfChannels: numChannels },
562
+ firstTimestampBehavior: "offset",
563
+ fastStart: "in-memory",
564
+ });
565
+
566
+ const audioEncoder = new AudioEncoder({
567
+ output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
568
+ error: e => console.error("AudioEncoder error:", e),
569
+ });
570
+ audioEncoder.configure({ codec: "mp4a.40.2", sampleRate, numberOfChannels: numChannels, bitrate: 128_000 });
571
+
572
+ const audioChunkSize = 4096;
573
+ for (let offset = 0; offset < audioLength; offset += audioChunkSize) {
574
+ const len = Math.min(audioChunkSize, audioLength - offset);
575
+ const audioData = new AudioData({
576
+ format: "f32-planar", sampleRate, numberOfFrames: len, numberOfChannels: numChannels,
577
+ timestamp: (offset / sampleRate) * 1_000_000,
578
+ data: (() => {
579
+ const buf = new Float32Array(len * numChannels);
580
+ for (let ch = 0; ch < numChannels; ch++) {
581
+ buf.set(decoded.getChannelData(ch).subarray(offset, offset + len), ch * len);
582
+ }
583
+ return buf;
584
+ })(),
585
+ });
586
+ audioEncoder.encode(audioData);
587
+ audioData.close();
588
+ }
589
+ await audioEncoder.flush();
590
+
591
+ const videoEncoder = new VideoEncoder({
592
+ output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
593
+ error: e => console.error("VideoEncoder error:", e),
594
+ });
595
+ videoEncoder.configure({ codec: "avc1.640028", width: W, height: H, bitrate: 2_000_000, framerate: FPS });
596
 
597
+ let frameIdx = 0;
598
+ const batchSize = 5;
599
  await new Promise((resolve) => {
600
+ function processBatch() {
601
+ const end = Math.min(frameIdx + batchSize, totalFrames);
602
+ for (; frameIdx < end; frameIdx++) {
603
+ const progress = frameIdx / totalFrames;
604
+ drawFrame(progress);
605
+ const frame = new VideoFrame(canvas, { timestamp: (frameIdx / FPS) * 1_000_000 });
606
+ videoEncoder.encode(frame, { keyFrame: frameIdx % (FPS * 2) === 0 });
607
+ frame.close();
608
+ }
609
+ pctEl.textContent = `${Math.round((frameIdx / totalFrames) * 100)}%`;
610
+ barEl.style.width = `${Math.round((frameIdx / totalFrames) * 100)}%`;
611
+ if (frameIdx < totalFrames) setTimeout(processBatch, 0);
612
+ else {
613
+ videoEncoder.flush().then(() => {
614
+ muxer.finalize();
615
+ const blob = new Blob([target.buffer], { type: "video/mp4" });
616
+ const url = URL.createObjectURL(blob);
617
+ const a = document.createElement("a");
618
+ a.href = url;
619
+ const safeName = (song.title || "song").replace(/[^a-zA-Z0-9 -]/g, "").trim().replace(/\s+/g, "-") || "song";
620
+ a.download = `${safeName}.mp4`;
621
+ a.click();
622
+ URL.revokeObjectURL(url);
623
+ resolve();
624
+ });
625
+ }
626
  }
627
+ processBatch();
 
 
 
 
 
 
 
 
 
 
 
 
628
  });
629
  } catch (e) {
630
  console.error("Video generation failed:", e);