victor HF Staff commited on
Commit
48834e0
Β·
1 Parent(s): a46506b

feat: video download for output + community songs

Browse files

Replace 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.

Files changed (1) hide show
  1. 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: transparent;
177
- border: 1px solid var(--border); border-radius: 6px; color: var(--text-muted);
178
- text-decoration: none; font-family: var(--font); font-size: 11px;
179
- font-weight: 500; padding: 4px 9px; transition: border-color 0.15s, color 0.15s;
 
180
  }
181
- .download-btn:hover { color: var(--text); border-color: rgba(255, 255, 255, 0.15); }
 
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
- <a class="download-btn" id="download-btn" download="output.wav">⬇ Download</a>
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
- downloadBtn.href = url;
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
- const safeName = (result.title || "output").replace(/[^a-zA-Z0-9 -]/g, "").trim().replace(/\s+/g, "-");
636
- downloadBtn.download = `${safeName}.wav`;
 
 
 
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();