Spaces:
Runtime error
Runtime error
feat: fast MP4 video generation via WebCodecs + mp4-muxer
Browse files- 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 |
-
// ββ
|
|
|
|
|
|
|
| 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
|
| 465 |
-
const decoded = await
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 567 |
-
|
| 568 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
|
|
|
|
|
|
|
| 570 |
await new Promise((resolve) => {
|
| 571 |
-
function
|
| 572 |
-
const
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
}
|
| 580 |
-
|
| 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);
|