Spaces:
Runtime error
Runtime error
feat: video download for output + community songs
Browse filesReplace WAV download with animated waveform video generation (.webm).
Output card "Download" button is now white/bright and generates a
1080x1080 video with thumbnail, title, tags, animated waveform
progress, and CTA link. Community cards get a white download icon
next to duration that does the same. Video renders in real-time
using Canvas + MediaRecorder with synced audio.
- index.html +210 -11
index.html
CHANGED
|
@@ -173,14 +173,37 @@
|
|
| 173 |
.play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
| 174 |
.time-display { font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 1; }
|
| 175 |
.download-btn {
|
| 176 |
-
display: inline-flex; align-items: center; gap: 4px; background:
|
| 177 |
-
border:
|
| 178 |
-
|
| 179 |
-
font-weight:
|
|
|
|
| 180 |
}
|
| 181 |
-
.download-btn:hover {
|
|
|
|
| 182 |
.download-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
/* ββ Community feed βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 185 |
.feed-header {
|
| 186 |
font-size: 11px; font-weight: 600; color: var(--text-muted);
|
|
@@ -321,7 +344,7 @@
|
|
| 321 |
<div class="playback-controls">
|
| 322 |
<button class="play-btn" id="play-btn" title="Play / Pause">βΆ</button>
|
| 323 |
<span class="time-display" id="time-display">0:00 / 0:00</span>
|
| 324 |
-
<
|
| 325 |
</div>
|
| 326 |
</div>
|
| 327 |
</div>
|
|
@@ -369,6 +392,10 @@
|
|
| 369 |
const downloadBtn = document.getElementById("download-btn");
|
| 370 |
const feedEl = document.getElementById("feed");
|
| 371 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
// ββ Main player (plain Audio + bar waveform) βββββββββββββββββββββββββββββ
|
| 373 |
const mainAudio = new Audio();
|
| 374 |
const waveformEl = document.getElementById("waveform");
|
|
@@ -405,6 +432,172 @@
|
|
| 405 |
}
|
| 406 |
});
|
| 407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
// ββ Gradio client βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 409 |
let gradioClient = null;
|
| 410 |
async function getClient() {
|
|
@@ -454,7 +647,7 @@
|
|
| 454 |
}
|
| 455 |
|
| 456 |
mainAudio.src = url;
|
| 457 |
-
|
| 458 |
outputSection.style.display = "block";
|
| 459 |
stopFeedAudio();
|
| 460 |
mainAudio.play();
|
|
@@ -558,21 +751,24 @@
|
|
| 558 |
<div class="song-title">${esc(song.title || "Untitled")}</div>
|
| 559 |
<div class="song-tags">${esc(song.tags || "")}</div>
|
| 560 |
<div class="song-duration">${dur}</div>
|
|
|
|
| 561 |
</div>
|
| 562 |
<div class="mini-waveform">${miniBars}</div>
|
| 563 |
`;
|
| 564 |
-
// Play button: toggle play/pause
|
| 565 |
card.querySelector(".song-play-btn").addEventListener("click", (e) => {
|
| 566 |
e.stopPropagation();
|
| 567 |
playFeedSong(card, song.audio_url);
|
| 568 |
});
|
| 569 |
-
// Waveform: seek to click position
|
| 570 |
card.querySelector(".mini-waveform").addEventListener("click", (e) => {
|
| 571 |
e.stopPropagation();
|
| 572 |
const rect = e.currentTarget.getBoundingClientRect();
|
| 573 |
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
| 574 |
playFeedSong(card, song.audio_url, ratio);
|
| 575 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
feedEl.appendChild(card);
|
| 577 |
}
|
| 578 |
} catch (e) {
|
|
@@ -632,8 +828,11 @@
|
|
| 632 |
const thumbEl = document.getElementById("song-thumb");
|
| 633 |
if (result.thumbnail) { thumbEl.src = result.thumbnail; thumbEl.style.display = "block"; }
|
| 634 |
else { thumbEl.style.display = "none"; }
|
| 635 |
-
|
| 636 |
-
|
|
|
|
|
|
|
|
|
|
| 637 |
await loadAudio(result.audio);
|
| 638 |
// Refresh feed if shared
|
| 639 |
if (communityChk.checked) loadFeed();
|
|
|
|
| 173 |
.play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
| 174 |
.time-display { font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 1; }
|
| 175 |
.download-btn {
|
| 176 |
+
display: inline-flex; align-items: center; gap: 4px; background: #fff;
|
| 177 |
+
border: none; border-radius: 6px; color: #111;
|
| 178 |
+
font-family: var(--font); font-size: 11px;
|
| 179 |
+
font-weight: 600; padding: 5px 10px; cursor: pointer;
|
| 180 |
+
transition: opacity 0.15s;
|
| 181 |
}
|
| 182 |
+
.download-btn:hover { opacity: 0.85; }
|
| 183 |
+
.download-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 184 |
.download-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
| 185 |
|
| 186 |
+
.share-icon-btn {
|
| 187 |
+
display: inline-flex; align-items: center; justify-content: center;
|
| 188 |
+
width: 20px; height: 20px; background: #fff; border: none; border-radius: 4px;
|
| 189 |
+
color: #111; font-size: 10px; cursor: pointer; flex-shrink: 0; margin-left: 4px;
|
| 190 |
+
transition: opacity 0.15s;
|
| 191 |
+
}
|
| 192 |
+
.share-icon-btn:hover { opacity: 0.85; }
|
| 193 |
+
.share-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 194 |
+
.share-icon-btn svg { width: 11px; height: 11px; }
|
| 195 |
+
|
| 196 |
+
/* Video generation overlay */
|
| 197 |
+
.video-overlay {
|
| 198 |
+
position: fixed; inset: 0; background: rgba(0,0,0,0.85);
|
| 199 |
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
| 200 |
+
z-index: 100; gap: 12px;
|
| 201 |
+
}
|
| 202 |
+
.video-overlay .label { font-size: 13px; color: var(--text-muted); }
|
| 203 |
+
.video-overlay .pct { font-size: 28px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
| 204 |
+
.video-overlay .bar-track { width: 200px; height: 3px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; }
|
| 205 |
+
.video-overlay .bar-fill { height: 100%; background: #fff; border-radius: 2px; transition: width 0.3s; }
|
| 206 |
+
|
| 207 |
/* ββ Community feed βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 208 |
.feed-header {
|
| 209 |
font-size: 11px; font-weight: 600; color: var(--text-muted);
|
|
|
|
| 344 |
<div class="playback-controls">
|
| 345 |
<button class="play-btn" id="play-btn" title="Play / Pause">βΆ</button>
|
| 346 |
<span class="time-display" id="time-display">0:00 / 0:00</span>
|
| 347 |
+
<button class="download-btn" id="download-btn">β¬ Download</button>
|
| 348 |
</div>
|
| 349 |
</div>
|
| 350 |
</div>
|
|
|
|
| 392 |
const downloadBtn = document.getElementById("download-btn");
|
| 393 |
const feedEl = document.getElementById("feed");
|
| 394 |
|
| 395 |
+
downloadBtn.addEventListener("click", () => {
|
| 396 |
+
if (currentSong) generateShareVideo(currentSong, downloadBtn);
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
// ββ Main player (plain Audio + bar waveform) βββββββββββββββββββββββββββββ
|
| 400 |
const mainAudio = new Audio();
|
| 401 |
const waveformEl = document.getElementById("waveform");
|
|
|
|
| 432 |
}
|
| 433 |
});
|
| 434 |
|
| 435 |
+
// ββ Current song state (for video generation) ββββββββββββββββββββββββββββ
|
| 436 |
+
let currentSong = null;
|
| 437 |
+
|
| 438 |
+
// ββ Video generation ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 439 |
+
async function generateShareVideo(song, btn) {
|
| 440 |
+
const origHTML = btn.innerHTML;
|
| 441 |
+
btn.disabled = true;
|
| 442 |
+
btn.textContent = "β¦";
|
| 443 |
+
stopFeedAudio();
|
| 444 |
+
mainAudio.pause();
|
| 445 |
+
|
| 446 |
+
const overlay = document.createElement("div");
|
| 447 |
+
overlay.className = "video-overlay";
|
| 448 |
+
overlay.innerHTML = `
|
| 449 |
+
<div class="label">Generating videoβ¦</div>
|
| 450 |
+
<div class="pct" id="vid-pct">0%</div>
|
| 451 |
+
<div class="bar-track"><div class="bar-fill" id="vid-bar" style="width:0%"></div></div>
|
| 452 |
+
`;
|
| 453 |
+
document.body.appendChild(overlay);
|
| 454 |
+
const pctEl = overlay.querySelector("#vid-pct");
|
| 455 |
+
const barEl = overlay.querySelector("#vid-bar");
|
| 456 |
+
|
| 457 |
+
try {
|
| 458 |
+
await document.fonts.ready;
|
| 459 |
+
|
| 460 |
+
const audioResp = await fetch(song.audio_url);
|
| 461 |
+
const audioArrayBuf = await audioResp.arrayBuffer();
|
| 462 |
+
const audioCtx = new AudioContext();
|
| 463 |
+
const decoded = await audioCtx.decodeAudioData(audioArrayBuf);
|
| 464 |
+
|
| 465 |
+
const raw = decoded.getChannelData(0);
|
| 466 |
+
const numBars = 60;
|
| 467 |
+
const blockSize = Math.floor(raw.length / numBars);
|
| 468 |
+
const peaks = [];
|
| 469 |
+
for (let i = 0; i < numBars; i++) {
|
| 470 |
+
let sum = 0;
|
| 471 |
+
for (let j = 0; j < blockSize; j++) sum += Math.abs(raw[i * blockSize + j]);
|
| 472 |
+
peaks.push(sum / blockSize);
|
| 473 |
+
}
|
| 474 |
+
const mx = Math.max(...peaks);
|
| 475 |
+
const normPeaks = peaks.map(p => mx > 0 ? p / mx : 0.5);
|
| 476 |
+
|
| 477 |
+
let thumbImg = null;
|
| 478 |
+
if (song.thumb_url) {
|
| 479 |
+
thumbImg = new Image();
|
| 480 |
+
thumbImg.crossOrigin = "anonymous";
|
| 481 |
+
await new Promise(r => { thumbImg.onload = r; thumbImg.onerror = r; thumbImg.src = song.thumb_url; });
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
const W = 1080, H = 1080;
|
| 485 |
+
const canvas = document.createElement("canvas");
|
| 486 |
+
canvas.width = W; canvas.height = H;
|
| 487 |
+
const ctx = canvas.getContext("2d");
|
| 488 |
+
|
| 489 |
+
const source = audioCtx.createBufferSource();
|
| 490 |
+
source.buffer = decoded;
|
| 491 |
+
const audioDest = audioCtx.createMediaStreamDestination();
|
| 492 |
+
source.connect(audioDest);
|
| 493 |
+
|
| 494 |
+
const canvasStream = canvas.captureStream(30);
|
| 495 |
+
const combined = new MediaStream([
|
| 496 |
+
...canvasStream.getTracks(),
|
| 497 |
+
...audioDest.stream.getTracks(),
|
| 498 |
+
]);
|
| 499 |
+
|
| 500 |
+
const recorder = new MediaRecorder(combined, {
|
| 501 |
+
mimeType: MediaRecorder.isTypeSupported("video/webm;codecs=vp9,opus")
|
| 502 |
+
? "video/webm;codecs=vp9,opus" : "video/webm",
|
| 503 |
+
videoBitsPerSecond: 2_000_000,
|
| 504 |
+
});
|
| 505 |
+
const chunks = [];
|
| 506 |
+
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
|
| 507 |
+
|
| 508 |
+
const duration = decoded.duration;
|
| 509 |
+
|
| 510 |
+
function drawFrame(progress) {
|
| 511 |
+
ctx.fillStyle = "#111111";
|
| 512 |
+
ctx.fillRect(0, 0, W, H);
|
| 513 |
+
|
| 514 |
+
const topY = 120;
|
| 515 |
+
if (thumbImg && thumbImg.naturalWidth > 0) {
|
| 516 |
+
ctx.save();
|
| 517 |
+
ctx.beginPath();
|
| 518 |
+
ctx.roundRect(90, topY, 120, 120, 14);
|
| 519 |
+
ctx.clip();
|
| 520 |
+
ctx.drawImage(thumbImg, 90, topY, 120, 120);
|
| 521 |
+
ctx.restore();
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
const textX = thumbImg && thumbImg.naturalWidth > 0 ? 230 : 90;
|
| 525 |
+
ctx.fillStyle = "#ffffff";
|
| 526 |
+
ctx.font = "600 36px 'Hanken Grotesk', sans-serif";
|
| 527 |
+
ctx.textAlign = "left";
|
| 528 |
+
ctx.textBaseline = "middle";
|
| 529 |
+
let title = song.title || "Untitled";
|
| 530 |
+
while (ctx.measureText(title).width > W - textX - 90 && title.length > 3) title = title.slice(0, -1);
|
| 531 |
+
if (title !== song.title) title += "β¦";
|
| 532 |
+
ctx.fillText(title, textX, topY + 45);
|
| 533 |
+
|
| 534 |
+
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
| 535 |
+
ctx.font = "400 22px 'Hanken Grotesk', sans-serif";
|
| 536 |
+
let tags = song.tags || "";
|
| 537 |
+
while (ctx.measureText(tags).width > W - textX - 90 && tags.length > 3) tags = tags.slice(0, -1);
|
| 538 |
+
if (tags !== song.tags) tags += "β¦";
|
| 539 |
+
ctx.fillText(tags, textX, topY + 90);
|
| 540 |
+
|
| 541 |
+
const waveY = 440, waveH = 220, pad = 80;
|
| 542 |
+
const totalW = W - pad * 2;
|
| 543 |
+
const gap = 3;
|
| 544 |
+
const barW = (totalW - (numBars - 1) * gap) / numBars;
|
| 545 |
+
for (let i = 0; i < numBars; i++) {
|
| 546 |
+
const x = pad + i * (barW + gap);
|
| 547 |
+
const h = Math.max(4, normPeaks[i] * waveH);
|
| 548 |
+
ctx.fillStyle = (i / numBars) <= progress ? "rgba(255,255,255,0.92)" : "rgba(255,255,255,0.15)";
|
| 549 |
+
ctx.beginPath();
|
| 550 |
+
ctx.roundRect(x, waveY + (waveH - h) / 2, barW, h, Math.min(barW / 2, 3));
|
| 551 |
+
ctx.fill();
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
ctx.textAlign = "center";
|
| 555 |
+
ctx.fillStyle = "rgba(255,255,255,0.40)";
|
| 556 |
+
ctx.font = "400 36px 'Hanken Grotesk', sans-serif";
|
| 557 |
+
ctx.fillText("Create your own song:", W / 2, 880);
|
| 558 |
+
ctx.fillStyle = "rgba(255,255,255,0.55)";
|
| 559 |
+
ctx.font = "500 33px 'Hanken Grotesk', sans-serif";
|
| 560 |
+
ctx.fillText("hf.co/spaces/victor/ace-step-jam", W / 2, 930);
|
| 561 |
+
ctx.textAlign = "left";
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
recorder.start(100);
|
| 565 |
+
source.start();
|
| 566 |
+
const startT = performance.now();
|
| 567 |
+
|
| 568 |
+
await new Promise((resolve) => {
|
| 569 |
+
function tick() {
|
| 570 |
+
const elapsed = (performance.now() - startT) / 1000;
|
| 571 |
+
const progress = Math.min(elapsed / duration, 1);
|
| 572 |
+
drawFrame(progress);
|
| 573 |
+
pctEl.textContent = `${Math.round(progress * 100)}%`;
|
| 574 |
+
barEl.style.width = `${Math.round(progress * 100)}%`;
|
| 575 |
+
if (progress < 1) requestAnimationFrame(tick);
|
| 576 |
+
else setTimeout(() => recorder.stop(), 200);
|
| 577 |
+
}
|
| 578 |
+
recorder.onstop = () => {
|
| 579 |
+
const blob = new Blob(chunks, { type: "video/webm" });
|
| 580 |
+
const url = URL.createObjectURL(blob);
|
| 581 |
+
const a = document.createElement("a");
|
| 582 |
+
a.href = url;
|
| 583 |
+
const safeName = (song.title || "song").replace(/[^a-zA-Z0-9 -]/g, "").trim().replace(/\s+/g, "-") || "song";
|
| 584 |
+
a.download = `${safeName}.webm`;
|
| 585 |
+
a.click();
|
| 586 |
+
URL.revokeObjectURL(url);
|
| 587 |
+
audioCtx.close();
|
| 588 |
+
resolve();
|
| 589 |
+
};
|
| 590 |
+
requestAnimationFrame(tick);
|
| 591 |
+
});
|
| 592 |
+
} catch (e) {
|
| 593 |
+
console.error("Video generation failed:", e);
|
| 594 |
+
} finally {
|
| 595 |
+
overlay.remove();
|
| 596 |
+
btn.disabled = false;
|
| 597 |
+
btn.innerHTML = origHTML;
|
| 598 |
+
}
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
// ββ Gradio client βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 602 |
let gradioClient = null;
|
| 603 |
async function getClient() {
|
|
|
|
| 647 |
}
|
| 648 |
|
| 649 |
mainAudio.src = url;
|
| 650 |
+
currentSong = { ...currentSong, audio_url: url };
|
| 651 |
outputSection.style.display = "block";
|
| 652 |
stopFeedAudio();
|
| 653 |
mainAudio.play();
|
|
|
|
| 751 |
<div class="song-title">${esc(song.title || "Untitled")}</div>
|
| 752 |
<div class="song-tags">${esc(song.tags || "")}</div>
|
| 753 |
<div class="song-duration">${dur}</div>
|
| 754 |
+
<button class="share-icon-btn" title="Download video"><svg viewBox="0 0 16 16" fill="currentColor"><path d="M3.5 13V8.5h1V12h7V8.5h1V13a.5.5 0 01-.5.5H4a.5.5 0 01-.5-.5zM8 10.25L4.75 7h2V2.5h2.5V7h2L8 10.25z"/></svg></button>
|
| 755 |
</div>
|
| 756 |
<div class="mini-waveform">${miniBars}</div>
|
| 757 |
`;
|
|
|
|
| 758 |
card.querySelector(".song-play-btn").addEventListener("click", (e) => {
|
| 759 |
e.stopPropagation();
|
| 760 |
playFeedSong(card, song.audio_url);
|
| 761 |
});
|
|
|
|
| 762 |
card.querySelector(".mini-waveform").addEventListener("click", (e) => {
|
| 763 |
e.stopPropagation();
|
| 764 |
const rect = e.currentTarget.getBoundingClientRect();
|
| 765 |
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
| 766 |
playFeedSong(card, song.audio_url, ratio);
|
| 767 |
});
|
| 768 |
+
card.querySelector(".share-icon-btn").addEventListener("click", (e) => {
|
| 769 |
+
e.stopPropagation();
|
| 770 |
+
generateShareVideo(song, e.currentTarget);
|
| 771 |
+
});
|
| 772 |
feedEl.appendChild(card);
|
| 773 |
}
|
| 774 |
} catch (e) {
|
|
|
|
| 828 |
const thumbEl = document.getElementById("song-thumb");
|
| 829 |
if (result.thumbnail) { thumbEl.src = result.thumbnail; thumbEl.style.display = "block"; }
|
| 830 |
else { thumbEl.style.display = "none"; }
|
| 831 |
+
currentSong = {
|
| 832 |
+
title: result.title || "Untitled",
|
| 833 |
+
tags: result.tags || "",
|
| 834 |
+
thumb_url: result.thumbnail || null,
|
| 835 |
+
};
|
| 836 |
await loadAudio(result.audio);
|
| 837 |
// Refresh feed if shared
|
| 838 |
if (communityChk.checked) loadFeed();
|