Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>MiniMax-Music3-Jam</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700&family=Outfit:wght@500&display=swap" rel="stylesheet" /> | |
| <script src="https://cdn.jsdelivr.net/npm/mp4-muxer@5.1.3/build/mp4-muxer.min.js"></script> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: oklch(0.13 0.006 260); | |
| --surface: rgba(255, 255, 255, 0.04); | |
| --surface-2: rgba(255, 255, 255, 0.06); | |
| --surface-3: rgba(255, 255, 255, 0.09); | |
| --border: rgba(255, 255, 255, 0.08); | |
| --accent: oklch(0.90 0.005 260); | |
| --accent-dim: oklch(0.40 0.005 260); | |
| --accent-glow: rgba(255, 255, 255, 0.08); | |
| --progress: oklch(0.72 0.14 155); | |
| --text: rgba(255, 255, 255, 0.87); | |
| --text-muted: rgba(255, 255, 255, 0.40); | |
| --text-faint: rgba(255, 255, 255, 0.25); | |
| --error: oklch(0.68 0.19 22); | |
| --success: oklch(0.72 0.14 155); | |
| --radius: 8px; | |
| --radius-lg: 10px; | |
| --font: 'Hanken Grotesk', system-ui, sans-serif; | |
| } | |
| html, body { | |
| height: 100%; background: var(--bg); color: var(--text); | |
| font-family: var(--font); font-size: 13.5px; line-height: 1.5; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| body { display: flex; flex-direction: column; min-height: 100vh; } | |
| /* ββ Layout βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .page { display: flex; flex: 1; max-width: 1100px; width: 100%; margin: 0 auto; padding: 28px 20px 0; gap: 20px; align-items: flex-start; } | |
| .col-left { flex: 1; min-width: 0; max-width: 480px; position: sticky; top: 0; background: var(--bg); } | |
| .col-right { flex: 1; min-width: 0; } | |
| /* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| header { padding: 0 0 20px; text-align: left; } | |
| /* Wordmark geometry is derived from the logo PNG (1200x224: text rows 65-152, text runs to the | |
| right edge) and the reference lockup: divider 1/4 mark-height from the "3" and from "jam", | |
| divider = 0.56 mark-height tall, "jam" x-height = 0.75 x "Music 3" cap height, same baseline. */ | |
| .wordmark { --h: 26px; display: inline-block; line-height: 0; white-space: nowrap; margin-bottom: 6px; } | |
| .wordmark-logo { | |
| height: var(--h); width: auto; display: inline-block; | |
| vertical-align: calc(var(--h) * -0.321); /* put the PNG's text baseline on the line baseline */ | |
| } | |
| .wordmark-divider { | |
| display: inline-block; width: 1px; height: calc(var(--h) * 0.56); | |
| background: rgb(148, 148, 149); vertical-align: calc(var(--h) * -0.125); | |
| margin-left: calc(var(--h) * 0.25); /* the PNG has no right padding: the "3" ends at its edge */ | |
| margin-right: calc(var(--h) * 0.25); | |
| } | |
| .wordmark-jam { | |
| display: inline-block; font-family: 'Outfit', 'Hanken Grotesk', system-ui, sans-serif; | |
| font-size: calc(var(--h) * 0.62); font-weight: 500; letter-spacing: 0.11em; line-height: 1; | |
| vertical-align: baseline; text-decoration: none; | |
| background: linear-gradient(90deg, #d42a68 0%, #dd4b5a 50%, #ef7640 100%); | |
| -webkit-background-clip: text; background-clip: text; color: transparent; | |
| transition: opacity 0.15s; | |
| } | |
| .wordmark-jam:hover { opacity: 0.8; } | |
| header p { font-size: 14px; color: var(--text-muted); } | |
| /* ββ Compose box βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .compose-box { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius-lg); margin-bottom: 10px; | |
| transition: border-color 0.15s; | |
| } | |
| .compose-box:focus-within { border-color: rgba(255, 255, 255, 0.14); } | |
| .compose-box #description-input { | |
| background: transparent; border: none; border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| padding: 14px 14px 6px; height: 80px; | |
| } | |
| .compose-box #description-input:focus, | |
| .compose-box #description-input:focus-visible { border: none; outline: none; } | |
| .compose-controls { | |
| display: flex; gap: 4px; padding: 4px 10px 10px; | |
| align-items: center; flex-wrap: wrap; | |
| } | |
| .compose-controls .pill { | |
| background: transparent; border: none; padding: 4px 8px; | |
| font-size: 11px; border-radius: 6px; | |
| } | |
| .compose-controls .pill:hover { background: var(--surface-2); } | |
| .compose-controls .pill select { font-size: 11px; min-width: 44px; background: transparent; border: none; } | |
| .compose-controls .pill input[type="checkbox"] { width: 11px; height: 11px; } | |
| .compose-controls .seed-input { | |
| width: 48px ; font-size: 11px ; padding: 3px 6px ; | |
| background: transparent ; border-radius: 6px ; | |
| } | |
| .compose-controls .generate-btn { | |
| width: auto; padding: 5px 14px; font-size: 12px; | |
| border-radius: 6px; letter-spacing: 0; | |
| } | |
| /* ββ Inputs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| textarea, input[type="text"], input[type="number"], select { | |
| width: 100%; background: var(--surface-2); border: 1px solid var(--border); | |
| border-radius: var(--radius); color: var(--text); font-family: var(--font); | |
| font-size: 13.5px; padding: 9px 11px; outline: none; transition: border-color 0.15s; | |
| } | |
| textarea:focus, input:focus, select:focus { border-color: rgba(255, 255, 255, 0.18); } | |
| textarea:focus-visible, input:focus-visible, select:focus-visible { outline: 1px solid rgba(255, 255, 255, 0.08); outline-offset: 1px; } | |
| textarea::placeholder, input::placeholder { color: var(--text-faint); } | |
| textarea { resize: none; } | |
| #description-input { height: 76px; font-size: 14px; } | |
| .prompt-hint { margin-top: 6px; font-size: 11px; color: var(--text-faint); } | |
| /* ββ Controls row βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .controls-row { display: flex; gap: 6px; margin-bottom: 10px; align-items: center; flex-wrap: wrap; } | |
| .pill { | |
| display: inline-flex; align-items: center; gap: 5px; | |
| font-size: 12px; color: var(--text-muted); cursor: pointer; user-select: none; | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius); padding: 6px 10px; white-space: nowrap; | |
| transition: border-color 0.15s; | |
| } | |
| .pill:hover { border-color: rgba(255, 255, 255, 0.12); } | |
| .pill input[type="checkbox"] { width: 12px; height: 12px; accent-color: var(--accent); cursor: pointer; } | |
| .pill:has(input:checked) span { color: var(--text); } | |
| .pill select { background: transparent; border: none; color: var(--text); font-size: 12px; padding: 0; min-width: 56px; } | |
| .seed-input { width: 72px ; font-size: 12px ; padding: 6px 8px ; } | |
| .spacer { flex: 1; } | |
| /* ββ Generate button ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .generate-btn { | |
| width: 100%; background: var(--accent); border: none; | |
| border-radius: var(--radius); color: oklch(0.13 0.006 260); font-family: var(--font); | |
| font-size: 14px; font-weight: 600; padding: 11px; cursor: pointer; | |
| transition: opacity 0.15s, transform 0.1s; letter-spacing: -0.01em; | |
| } | |
| .generate-btn:hover:not(:disabled) { opacity: 0.88; } | |
| .generate-btn:active:not(:disabled) { transform: scale(0.99); } | |
| .generate-btn:disabled { background: var(--accent-dim); cursor: not-allowed; opacity: 0.5; } | |
| .generate-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| .generate-btn.loading { animation: btn-pulse 2s ease-in-out infinite; } | |
| @keyframes btn-pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.7; } | |
| } | |
| /* ββ Status βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .status-bar { font-size: 11.5px; color: var(--text-muted); text-align: center; padding: 0; min-height: 0; display: none; } | |
| .status-bar.error { display: block; color: var(--error); padding: 6px 0; } | |
| .status-bar.success { display: none; } | |
| /* ββ Output card ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .output-card { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius-lg); padding: 14px; margin-bottom: 10px; | |
| animation: fade-in 0.25s ease; | |
| } | |
| @keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } | |
| #waveform { | |
| display: flex; align-items: end; gap: 1px; height: 56px; | |
| background: var(--surface-2); border-radius: var(--radius); | |
| padding: 8px 10px; cursor: pointer; margin-bottom: 10px; | |
| } | |
| #waveform .bar { | |
| flex: 1; background: var(--text-faint); border-radius: 1px; | |
| transition: background 0.15s; | |
| } | |
| .playback-controls { display: flex; align-items: center; gap: 10px; } | |
| .play-btn { | |
| width: 30px; height: 30px; background: var(--accent); border: none; | |
| border-radius: 50%; color: oklch(0.13 0.006 260); font-size: 12px; cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; flex-shrink: 0; | |
| transition: opacity 0.15s; | |
| } | |
| .play-btn:hover { opacity: 0.85; } | |
| .play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| .time-display { font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 1; } | |
| .download-btn { | |
| display: inline-flex; align-items: center; gap: 4px; background: #fff; | |
| border: none; border-radius: 6px; color: #111; | |
| font-family: var(--font); font-size: 11px; | |
| font-weight: 600; padding: 5px 10px; cursor: pointer; | |
| transition: opacity 0.15s; | |
| } | |
| .download-btn:hover { opacity: 0.85; } | |
| .download-btn:disabled { opacity: 0.4; cursor: not-allowed; } | |
| .download-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } | |
| .share-icon-btn { | |
| display: inline-flex; align-items: center; justify-content: center; | |
| width: 16px; height: 16px; background: #fff; border: none; border-radius: 3px; | |
| color: #111; font-size: 10px; cursor: pointer; flex-shrink: 0; margin-left: 4px; | |
| transition: opacity 0.15s; | |
| } | |
| .share-icon-btn:hover { opacity: 0.85; } | |
| .share-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; } | |
| .share-icon-btn svg { width: 9px; height: 9px; } | |
| /* Video generation overlay */ | |
| .video-overlay { | |
| position: fixed; inset: 0; background: rgba(0,0,0,0.85); | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| z-index: 100; gap: 12px; | |
| } | |
| .video-overlay .label { font-size: 13px; color: var(--text-muted); } | |
| .video-overlay .pct { font-size: 28px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } | |
| .video-overlay .bar-track { width: 200px; height: 3px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; } | |
| .video-overlay .bar-fill { height: 100%; background: #fff; border-radius: 2px; transition: width 0.3s; } | |
| /* ββ Community feed βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .feed-header { | |
| font-size: 11px; font-weight: 600; color: var(--text-muted); | |
| text-transform: uppercase; letter-spacing: 0.06em; | |
| margin-bottom: 10px; | |
| } | |
| .feed-empty { font-size: 12px; color: var(--text-faint); text-align: center; padding: 32px 0; } | |
| .song-card { | |
| background: transparent; border: 1px solid transparent; | |
| border-radius: var(--radius); padding: 8px 10px; margin-bottom: 6px; | |
| cursor: pointer; transition: background 0.15s; | |
| } | |
| .song-card:hover { background: var(--surface); } | |
| .song-card.playing { background: var(--surface); } | |
| .song-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; } | |
| .song-play-btn { | |
| width: 26px; height: 26px; background: var(--surface-3); border: 1px solid var(--border); | |
| border-radius: 50%; color: var(--text-muted); font-size: 10px; cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; flex-shrink: 0; | |
| transition: background 0.15s, color 0.15s, border-color 0.15s; | |
| } | |
| .song-play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } | |
| .song-card:hover .song-play-btn { background: rgba(255, 255, 255, 0.08); color: var(--text); border-color: rgba(255, 255, 255, 0.12); } | |
| .song-card.playing .song-play-btn { background: var(--accent); color: oklch(0.13 0.006 260); border-color: var(--accent); } | |
| .song-title { font-size: 13px; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .song-tags { font-size: 11px; color: var(--text-muted); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; } | |
| .song-duration { font-size: 11px; color: var(--text-muted); flex-shrink: 0; font-variant-numeric: tabular-nums; margin-left: 8px; } | |
| /* ββ Placeholder (loading state) ββββββββββββββββββββββββββββββββββββββββ */ | |
| .placeholder-card { | |
| background: var(--surface); border: 1px solid rgba(255, 255, 255, 0.12); | |
| border-radius: var(--radius-lg); padding: 14px; | |
| margin-bottom: 10px; animation: fade-in 0.25s ease; | |
| } | |
| .placeholder-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; } | |
| .placeholder-sub { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; } | |
| .fake-waveform { | |
| display: flex; align-items: end; gap: 2px; height: 44px; | |
| background: var(--surface-2); border-radius: var(--radius); padding: 8px 10px; | |
| } | |
| .fake-waveform .bar { | |
| flex: 1; background: var(--accent); border-radius: 1px; opacity: 0.3; | |
| animation: wave-pulse 1.2s ease-in-out infinite; | |
| } | |
| @keyframes wave-pulse { | |
| 0%, 100% { transform: scaleY(0.3); opacity: 0.15; } | |
| 50% { transform: scaleY(1); opacity: 0.4; } | |
| } | |
| /* ββ Mini waveform ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .mini-waveform { | |
| display: flex; align-items: end; gap: 1px; height: 22px; margin-top: 5px; | |
| cursor: pointer; | |
| } | |
| .mini-waveform .bar { | |
| flex: 1; background: var(--text-faint); border-radius: 1px; | |
| transition: background 0.2s; | |
| } | |
| .song-card.playing .mini-waveform .bar { background: var(--accent); } | |
| .song-card:hover .mini-waveform .bar { background: rgba(255, 255, 255, 0.22); } | |
| /* ββ Spinner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .spinner { | |
| display: inline-block; width: 12px; height: 12px; | |
| border: 2px solid rgba(0, 0, 0, 0.12); border-top-color: oklch(0.13 0.006 260); | |
| border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* ββ Footer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| footer { padding: 20px; text-align: center; font-size: 11px; color: var(--text-faint); } | |
| footer a { color: var(--text-muted); text-decoration: none; } | |
| footer a:hover { color: var(--text); } | |
| /* ββ Upsell βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .upsell { | |
| font-size: 11px; color: var(--text-faint); text-align: center; | |
| margin: 6px 0 2px; padding: 0; | |
| } | |
| .upsell a { color: var(--text-muted); text-decoration: none; border-bottom: 1px dotted var(--border); } | |
| .upsell a:hover { color: var(--text); border-bottom-color: var(--text-muted); } | |
| .upsell.hidden { display: none; } | |
| .upsell .heart-icon { width: 11px; height: 11px; vertical-align: -1px; margin-left: 1px; } | |
| /* ββ Dates & lyrics βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .song-meta-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; } | |
| .song-date { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; white-space: nowrap; } | |
| .lyrics-toggle { | |
| background: transparent; border: none; padding: 0; color: var(--text-faint); | |
| font-family: var(--font); font-size: 11px; cursor: pointer; text-decoration: underline dotted; | |
| text-underline-offset: 2px; | |
| } | |
| .lyrics-toggle:hover, .lyrics-toggle.open { color: var(--text); } | |
| .lyrics-box { | |
| font-family: var(--font); font-size: 12px; line-height: 1.55; color: var(--text-muted); | |
| white-space: pre-wrap; background: var(--surface-2); border-radius: var(--radius); | |
| padding: 10px 12px; margin: 0 0 10px; max-height: 240px; overflow: auto; | |
| } | |
| .lyrics-box[hidden] { display: none; } | |
| .song-card .lyrics-box { margin: 8px 0 2px; max-height: 200px; } | |
| .song-card-bottom { display: flex; align-items: center; gap: 8px; margin-top: 4px; } | |
| .song-card-bottom .song-date { flex: 1; } | |
| /* ββ Mobile βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .share-label-short { display: none; } | |
| @media (max-width: 800px) { | |
| .page { flex-direction: column; padding: 20px 16px 0; gap: 16px; } | |
| .col-left, .col-right { max-width: none; width: 100%; } | |
| .song-card-top .song-tags { display: none; } | |
| .controls-row { gap: 5px; } | |
| .seed-input { width: 60px ; } | |
| .share-label-full { display: none; } | |
| .share-label-short { display: inline; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="page"> | |
| <!-- ββββ LEFT: Create ββββ --> | |
| <div class="col-left"> | |
| <header> | |
| <div class="wordmark"> | |
| <img class="wordmark-logo" src="logo.png" alt="MiniMax Music 3" /><span class="wordmark-divider" aria-hidden="true"></span><a href="https://huggingface.co/MiniMaxAI/MiniMax-Music3" target="_blank" class="wordmark-jam">jam</a> | |
| </div> | |
| <p>Describe any song. An LLM writes it, MiniMax Music 3 sings it.</p> | |
| </header> | |
| <div class="compose-box"> | |
| <textarea id="description-input" | |
| placeholder="A dreamy synthwave song about a night drive through a neon city, female vocalsβ¦" | |
| ></textarea> | |
| <div class="compose-controls"> | |
| <label class="pill"> | |
| <select id="duration-select"> | |
| <option value="30">30s</option> | |
| <option value="60" selected>1 min</option> | |
| <option value="120">2 min</option> | |
| </select> | |
| </label> | |
| <label class="pill"><input type="checkbox" id="instrumental-check" /><span>Instrumental</span></label> | |
| <label class="pill"><input type="checkbox" id="community-check" checked /><span class="share-label-full">Share in community</span><span class="share-label-short">Share</span></label> | |
| <span class="spacer"></span> | |
| <button class="generate-btn" id="generate-btn">Generate</button> | |
| </div> | |
| </div> | |
| <p class="upsell" id="upsell-studio"> | |
| Don't forget to | |
| <a href="https://huggingface.co/MiniMaxAI/MiniMax-Music3" target="_blank" rel="noopener">like the model <svg class="heart-icon" viewBox="0 0 24 24" fill="#e53935" aria-hidden="true"><path d="M12 21s-7.09-4.35-9.64-8.36C.66 10.02 1.24 6.6 4.1 5.02c2.2-1.22 4.9-.63 6.4 1.18.6.72 1.02 1.55 1.5 1.55s.9-.83 1.5-1.55c1.5-1.81 4.2-2.4 6.4-1.18 2.86 1.58 3.44 5 1.74 7.62C19.09 16.65 12 21 12 21z"/></svg></a> | |
| </p> | |
| <div class="status-bar" id="status-bar"></div> | |
| <!-- Output --> | |
| <div id="output-section" style="display:none"> | |
| <div class="output-card"> | |
| <div style="display:flex;gap:10px;margin-bottom:8px;"> | |
| <img id="song-thumb" style="width:48px;height:48px;border-radius:6px;object-fit:cover;display:none;" /> | |
| <div style="min-width:0;flex:1;"> | |
| <div id="song-title" style="font-size:13px;font-weight:600;margin-bottom:3px;"></div> | |
| <div id="song-tags" style="font-size:11px;color:var(--text-muted);"></div> | |
| <div class="song-meta-row"><span id="song-date" class="song-date"></span><button class="lyrics-toggle" id="lyrics-toggle" type="button">Lyrics</button></div> | |
| </div> | |
| </div> | |
| <pre class="lyrics-box" id="lyrics-box" hidden></pre> | |
| <div id="waveform"></div> | |
| <div class="playback-controls"> | |
| <button class="play-btn" id="play-btn" title="Play / Pause"><svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg></button> | |
| <span class="time-display" id="time-display">0:00 / 0:00</span> | |
| <button class="download-btn" id="download-btn">β¬ Download</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ββββ RIGHT: Community feed ββββ --> | |
| <div class="col-right"> | |
| <div class="feed-header">Community Songs</div> | |
| <div id="feed"></div> | |
| </div> | |
| </div> | |
| <footer> | |
| Powered by <a href="https://huggingface.co/MiniMaxAI/MiniMax-Music3" target="_blank">MiniMax Music 3</a> Β· | |
| <a href="https://huggingface.co/spaces/multimodalart/minimax-music3-prompting-guide" target="_blank">Prompting guide</a> Β· | |
| <a href="https://huggingface.co/spaces/MiniMaxAI/MiniMax-Music3" target="_blank">Official Studio</a> Β· | |
| <a href="https://huggingface.co/buckets/victor/minimax-music3-community" target="_blank">Community bucket</a> | |
| </footer> | |
| <script type="module"> | |
| // ββ ZeroGPU auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ZEROGPU_HEADERS_MSG = "supports-zerogpu-headers"; | |
| window.addEventListener("message", (e) => { if (e.data === ZEROGPU_HEADERS_MSG) window.supports_zerogpu_headers = true; }); | |
| const _hn = window.location.hostname; | |
| if (_hn.endsWith(".hf.space") || _hn.includes(".dev.")) { | |
| const _o = _hn.includes(".dev.") ? `https://moon-${_hn.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co"; | |
| window.parent.postMessage(ZEROGPU_HEADERS_MSG, _o); | |
| } | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.15.2/dist/index.min.js"; | |
| // WaveSurfer removed β using lightweight bar waveform with real audio peaks | |
| // ββ DOM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const descInput = document.getElementById("description-input"); | |
| const durationSelect = document.getElementById("duration-select"); | |
| const instrumentalChk = document.getElementById("instrumental-check"); | |
| const communityChk = document.getElementById("community-check"); | |
| const generateBtn = document.getElementById("generate-btn"); | |
| const statusBar = document.getElementById("status-bar"); | |
| const outputSection = document.getElementById("output-section"); | |
| const songTitleEl = document.getElementById("song-title"); | |
| const songTagsEl = document.getElementById("song-tags"); | |
| const playBtn = document.getElementById("play-btn"); | |
| const timeDisplay = document.getElementById("time-display"); | |
| const downloadBtn = document.getElementById("download-btn"); | |
| const feedEl = document.getElementById("feed"); | |
| const songDateEl = document.getElementById("song-date"); | |
| const lyricsToggle = document.getElementById("lyrics-toggle"); | |
| const lyricsBox = document.getElementById("lyrics-box"); | |
| // ββ Dates ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function fmtDate(iso) { | |
| if (!iso) return ""; | |
| const d = new Date(iso); | |
| if (isNaN(d)) return ""; | |
| const diff = (Date.now() - d.getTime()) / 1000; | |
| if (diff < 60) return "just now"; | |
| if (diff < 3600) return `${Math.floor(diff / 60)} min ago`; | |
| if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; | |
| if (diff < 7 * 86400) return `${Math.floor(diff / 86400)}d ago`; | |
| const opts = { month: "short", day: "numeric" }; | |
| if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric"; | |
| return d.toLocaleDateString(undefined, opts); | |
| } | |
| function fullDate(iso) { | |
| const d = new Date(iso); | |
| return isNaN(d) ? "" : d.toLocaleString(); | |
| } | |
| // ββ Lyrics toggles βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function wireLyricsToggle(btn, box, lyrics) { | |
| if (!lyrics || !lyrics.trim()) { btn.style.display = "none"; box.hidden = true; return; } | |
| btn.style.display = ""; | |
| box.textContent = lyrics.trim(); | |
| box.hidden = true; | |
| btn.classList.remove("open"); | |
| btn.onclick = (e) => { | |
| e.stopPropagation(); | |
| box.hidden = !box.hidden; | |
| btn.classList.toggle("open", !box.hidden); | |
| }; | |
| } | |
| downloadBtn.addEventListener("click", () => { | |
| if (currentSong) generateShareVideo(currentSong, downloadBtn); | |
| }); | |
| // ββ Main player (plain Audio + bar waveform) βββββββββββββββββββββββββββββ | |
| const mainAudio = new Audio(); | |
| const waveformEl = document.getElementById("waveform"); | |
| function fmt(s) { return `${Math.floor(s/60)}:${Math.floor(s%60).toString().padStart(2,"0")}`; } | |
| function updateTime() { | |
| const c = mainAudio.currentTime || 0, d = mainAudio.duration || 0; | |
| timeDisplay.textContent = `${fmt(c)} / ${fmt(d)}`; | |
| } | |
| function updateMainProgress() { | |
| if (mainAudio.paused && mainAudio.currentTime === 0) return; | |
| const pct = mainAudio.duration ? mainAudio.currentTime / mainAudio.duration : 0; | |
| waveformEl.querySelectorAll(".bar").forEach((b, i, arr) => { | |
| b.style.background = (i / arr.length) <= pct ? "var(--accent)" : ""; | |
| }); | |
| updateTime(); | |
| if (!mainAudio.paused) requestAnimationFrame(updateMainProgress); | |
| } | |
| const playIcon = '<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>'; | |
| const pauseIcon = '<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><rect x="0" y="0" width="3" height="12"/><rect x="7" y="0" width="3" height="12"/></svg>'; | |
| mainAudio.addEventListener("play", () => { playBtn.innerHTML = pauseIcon; requestAnimationFrame(updateMainProgress); }); | |
| mainAudio.addEventListener("pause", () => { playBtn.innerHTML = playIcon; }); | |
| mainAudio.addEventListener("ended", () => { playBtn.innerHTML = playIcon; waveformEl.querySelectorAll(".bar").forEach(b => b.style.background = ""); }); | |
| mainAudio.addEventListener("timeupdate", updateTime); | |
| playBtn.addEventListener("click", () => { stopFeedAudio(); mainAudio.paused ? mainAudio.play() : mainAudio.pause(); }); | |
| waveformEl.addEventListener("click", (e) => { | |
| const rect = waveformEl.getBoundingClientRect(); | |
| const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); | |
| if (mainAudio.duration) { | |
| stopFeedAudio(); | |
| mainAudio.currentTime = mainAudio.duration * ratio; | |
| if (mainAudio.paused) mainAudio.play(); | |
| requestAnimationFrame(updateMainProgress); | |
| } | |
| }); | |
| // ββ Current song state (for video generation) ββββββββββββββββββββββββββββ | |
| let currentSong = null; | |
| // ββ Fast video generation (WebCodecs + MP4 muxer) βββββββββββββββββββββ | |
| const { Muxer: Mp4MuxerClass, ArrayBufferTarget } = Mp4Muxer; | |
| async function generateShareVideo(song, btn) { | |
| const origHTML = btn.innerHTML; | |
| btn.disabled = true; | |
| btn.textContent = "β¦"; | |
| stopFeedAudio(); | |
| mainAudio.pause(); | |
| const overlay = document.createElement("div"); | |
| overlay.className = "video-overlay"; | |
| overlay.innerHTML = ` | |
| <div class="label">Generating videoβ¦</div> | |
| <div class="pct" id="vid-pct">0%</div> | |
| <div class="bar-track"><div class="bar-fill" id="vid-bar" style="width:0%"></div></div> | |
| `; | |
| document.body.appendChild(overlay); | |
| const pctEl = overlay.querySelector("#vid-pct"); | |
| const barEl = overlay.querySelector("#vid-bar"); | |
| try { | |
| await document.fonts.ready; | |
| const audioResp = await fetch(song.audio_url); | |
| const audioArrayBuf = await audioResp.arrayBuffer(); | |
| const offCtx = new OfflineAudioContext(2, 1, 44100); | |
| const decoded = await offCtx.decodeAudioData(audioArrayBuf); | |
| const sampleRate = decoded.sampleRate; | |
| const numChannels = decoded.numberOfChannels; | |
| const audioLength = decoded.length; | |
| const raw = decoded.getChannelData(0); | |
| const numBars = 60; | |
| const blockSize = Math.floor(raw.length / numBars); | |
| const peaks = []; | |
| for (let i = 0; i < numBars; i++) { | |
| let sum = 0; | |
| for (let j = 0; j < blockSize; j++) sum += Math.abs(raw[i * blockSize + j]); | |
| peaks.push(sum / blockSize); | |
| } | |
| const mx = Math.max(...peaks); | |
| const normPeaks = peaks.map(p => mx > 0 ? p / mx : 0.5); | |
| let thumbImg = null; | |
| if (song.thumb_url) { | |
| thumbImg = new Image(); | |
| thumbImg.crossOrigin = "anonymous"; | |
| await new Promise(r => { thumbImg.onload = r; thumbImg.onerror = r; thumbImg.src = song.thumb_url; }); | |
| } | |
| const W = 1080, H = 1080; | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = W; canvas.height = H; | |
| const ctx = canvas.getContext("2d", { willReadFrequently: true }); | |
| const duration = decoded.duration; | |
| const FPS = 15; | |
| const totalFrames = Math.ceil(duration * FPS); | |
| function drawFrame(progress) { | |
| ctx.fillStyle = "#111111"; | |
| ctx.fillRect(0, 0, W, H); | |
| const topY = 120; | |
| if (thumbImg && thumbImg.naturalWidth > 0) { | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.roundRect(90, topY, 120, 120, 14); | |
| ctx.clip(); | |
| ctx.drawImage(thumbImg, 90, topY, 120, 120); | |
| ctx.restore(); | |
| } | |
| const textX = thumbImg && thumbImg.naturalWidth > 0 ? 230 : 90; | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.font = "600 36px 'Hanken Grotesk', sans-serif"; | |
| ctx.textAlign = "left"; | |
| ctx.textBaseline = "middle"; | |
| let title = song.title || "Untitled"; | |
| while (ctx.measureText(title).width > W - textX - 90 && title.length > 3) title = title.slice(0, -1); | |
| if (title !== song.title) title += "β¦"; | |
| ctx.fillText(title, textX, topY + 45); | |
| ctx.fillStyle = "rgba(255,255,255,0.45)"; | |
| ctx.font = "400 22px 'Hanken Grotesk', sans-serif"; | |
| let tags = song.tags || ""; | |
| while (ctx.measureText(tags).width > W - textX - 90 && tags.length > 3) tags = tags.slice(0, -1); | |
| if (tags !== song.tags) tags += "β¦"; | |
| ctx.fillText(tags, textX, topY + 90); | |
| const waveY = 440, waveH = 220, pad = 80; | |
| const totalW = W - pad * 2; | |
| const gap = 3; | |
| const barW = (totalW - (numBars - 1) * gap) / numBars; | |
| for (let i = 0; i < numBars; i++) { | |
| const x = pad + i * (barW + gap); | |
| const h = Math.max(4, normPeaks[i] * waveH); | |
| ctx.fillStyle = (i / numBars) <= progress ? "rgba(255,255,255,0.92)" : "rgba(255,255,255,0.15)"; | |
| ctx.beginPath(); | |
| ctx.roundRect(x, waveY + (waveH - h) / 2, barW, h, Math.min(barW / 2, 3)); | |
| ctx.fill(); | |
| } | |
| ctx.textAlign = "center"; | |
| ctx.fillStyle = "rgba(255,255,255,0.40)"; | |
| ctx.font = "400 36px 'Hanken Grotesk', sans-serif"; | |
| ctx.fillText("Create your own song:", W / 2, 880); | |
| ctx.fillStyle = "rgba(255,255,255,0.55)"; | |
| ctx.font = "500 33px 'Hanken Grotesk', sans-serif"; | |
| ctx.fillText("hf.co/spaces/victor/MiniMax-Music3-Jam", W / 2, 930); | |
| ctx.textAlign = "left"; | |
| } | |
| // ββ Encode with WebCodecs + MP4 muxer ββ | |
| const target = new ArrayBufferTarget(); | |
| const muxer = new Mp4MuxerClass({ | |
| target, | |
| video: { codec: "avc", width: W, height: H }, | |
| audio: { codec: "aac", sampleRate, numberOfChannels: numChannels }, | |
| firstTimestampBehavior: "offset", | |
| fastStart: "in-memory", | |
| }); | |
| const audioEncoder = new AudioEncoder({ | |
| output: (chunk, meta) => muxer.addAudioChunk(chunk, meta), | |
| error: e => console.error("AudioEncoder error:", e), | |
| }); | |
| audioEncoder.configure({ codec: "mp4a.40.2", sampleRate, numberOfChannels: numChannels, bitrate: 128_000 }); | |
| const audioChunkSize = 4096; | |
| for (let offset = 0; offset < audioLength; offset += audioChunkSize) { | |
| const len = Math.min(audioChunkSize, audioLength - offset); | |
| const audioData = new AudioData({ | |
| format: "f32-planar", sampleRate, numberOfFrames: len, numberOfChannels: numChannels, | |
| timestamp: (offset / sampleRate) * 1_000_000, | |
| data: (() => { | |
| const buf = new Float32Array(len * numChannels); | |
| for (let ch = 0; ch < numChannels; ch++) { | |
| buf.set(decoded.getChannelData(ch).subarray(offset, offset + len), ch * len); | |
| } | |
| return buf; | |
| })(), | |
| }); | |
| audioEncoder.encode(audioData); | |
| audioData.close(); | |
| } | |
| await audioEncoder.flush(); | |
| const videoEncoder = new VideoEncoder({ | |
| output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), | |
| error: e => console.error("VideoEncoder error:", e), | |
| }); | |
| videoEncoder.configure({ codec: "avc1.640028", width: W, height: H, bitrate: 2_000_000, framerate: FPS }); | |
| let frameIdx = 0; | |
| const batchSize = 5; | |
| await new Promise((resolve) => { | |
| function processBatch() { | |
| const end = Math.min(frameIdx + batchSize, totalFrames); | |
| for (; frameIdx < end; frameIdx++) { | |
| const progress = frameIdx / totalFrames; | |
| drawFrame(progress); | |
| const frame = new VideoFrame(canvas, { timestamp: (frameIdx / FPS) * 1_000_000 }); | |
| videoEncoder.encode(frame, { keyFrame: frameIdx % (FPS * 2) === 0 }); | |
| frame.close(); | |
| } | |
| pctEl.textContent = `${Math.round((frameIdx / totalFrames) * 100)}%`; | |
| barEl.style.width = `${Math.round((frameIdx / totalFrames) * 100)}%`; | |
| if (frameIdx < totalFrames) setTimeout(processBatch, 0); | |
| else { | |
| videoEncoder.flush().then(() => { | |
| muxer.finalize(); | |
| const blob = new Blob([target.buffer], { type: "video/mp4" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| const safeName = (song.title || "song").replace(/[^a-zA-Z0-9 -]/g, "").trim().replace(/\s+/g, "-") || "song"; | |
| a.download = `${safeName}.mp4`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| resolve(); | |
| }); | |
| } | |
| } | |
| processBatch(); | |
| }); | |
| } catch (e) { | |
| console.error("Video generation failed:", e); | |
| } finally { | |
| overlay.remove(); | |
| btn.disabled = false; | |
| btn.innerHTML = origHTML; | |
| } | |
| } | |
| // ββ Gradio client βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let gradioClient = null; | |
| async function getClient() { | |
| if (!gradioClient) gradioClient = await Client.connect(window.location.origin); | |
| return gradioClient; | |
| } | |
| // ββ UI helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function setLoading(on) { | |
| generateBtn.disabled = on; | |
| if (on) { generateBtn.innerHTML = '<span class="spinner"></span> Creatingβ¦'; generateBtn.classList.add("loading"); } | |
| else { generateBtn.textContent = "Generate"; generateBtn.classList.remove("loading"); } | |
| if (on) document.getElementById("upsell-studio")?.classList.add("hidden"); | |
| } | |
| function setStatus(msg, type = "") { | |
| statusBar.textContent = msg; | |
| statusBar.className = "status-bar" + (type ? ` ${type}` : ""); | |
| } | |
| async function loadAudio(dataUrl) { | |
| const res = await fetch(dataUrl); | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| // Extract real peaks for bar waveform | |
| const numBars = 80; | |
| try { | |
| const buf = await blob.arrayBuffer(); | |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const decoded = await ctx.decodeAudioData(buf); | |
| const raw = decoded.getChannelData(0); | |
| const block = Math.floor(raw.length / numBars); | |
| const peaks = []; | |
| for (let i = 0; i < numBars; i++) { | |
| let sum = 0; | |
| for (let j = 0; j < block; j++) sum += Math.abs(raw[i * block + j]); | |
| peaks.push(sum / block); | |
| } | |
| const mx = Math.max(...peaks); | |
| waveformEl.innerHTML = peaks.map(p => { | |
| const h = mx > 0 ? 15 + (p / mx) * 85 : 50; | |
| return `<div class="bar" style="height:${h}%"></div>`; | |
| }).join(""); | |
| } catch { | |
| waveformEl.innerHTML = Array.from({length: numBars}, () => | |
| `<div class="bar" style="height:${15 + Math.random() * 85}%"></div>` | |
| ).join(""); | |
| } | |
| mainAudio.src = url; | |
| currentSong = { ...currentSong, audio_url: url }; | |
| outputSection.style.display = "block"; | |
| document.getElementById("upsell-studio")?.classList.add("hidden"); | |
| stopFeedAudio(); | |
| mainAudio.play(); | |
| } | |
| // ββ Community feed player βββββββββββββββββββββββββββββββββββββββββββββββ | |
| let feedAudio = null; | |
| let playingCard = null; | |
| function stopFeedAudio() { | |
| if (feedAudio) { feedAudio.pause(); feedAudio = null; } | |
| if (playingCard) { | |
| playingCard.querySelectorAll(".mini-waveform .bar").forEach(b => b.style.background = ""); | |
| playingCard.classList.remove("playing"); | |
| playingCard.querySelector(".song-play-btn").innerHTML = '<svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>'; | |
| playingCard = null; | |
| } | |
| } | |
| function playFeedSong(card, audioUrl, seekRatio) { | |
| // If same card and already playing, just seek (or pause if clicking play btn) | |
| if (playingCard === card && feedAudio) { | |
| if (seekRatio != null) { | |
| feedAudio.currentTime = feedAudio.duration * seekRatio; | |
| if (feedAudio.paused) feedAudio.play(); | |
| } else { | |
| // Toggle play/pause (clicked the play button) | |
| if (feedAudio.paused) { feedAudio.play(); } | |
| else { feedAudio.pause(); } | |
| } | |
| return; | |
| } | |
| stopFeedAudio(); | |
| mainAudio.pause(); | |
| feedAudio = new Audio(audioUrl); | |
| playingCard = card; | |
| card.classList.add("playing"); | |
| card.querySelector(".song-play-btn").innerHTML = '<svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><rect x="0" y="0" width="3" height="12"/><rect x="7" y="0" width="3" height="12"/></svg>'; | |
| feedAudio.play(); | |
| if (seekRatio != null) { | |
| feedAudio.addEventListener("loadedmetadata", () => { | |
| feedAudio.currentTime = feedAudio.duration * seekRatio; | |
| }, { once: true }); | |
| } | |
| // Update waveform progress during playback | |
| const waveEl = card.querySelector(".mini-waveform"); | |
| const bars = waveEl ? waveEl.querySelectorAll(".bar") : []; | |
| function updateProgress() { | |
| if (playingCard !== card || !feedAudio || feedAudio.paused) return; | |
| const pct = feedAudio.currentTime / feedAudio.duration; | |
| bars.forEach((b, i) => { | |
| b.style.background = (i / bars.length) <= pct ? "var(--accent)" : "var(--text-faint)"; | |
| }); | |
| requestAnimationFrame(updateProgress); | |
| } | |
| if (bars.length) requestAnimationFrame(updateProgress); | |
| feedAudio.addEventListener("ended", () => { | |
| bars.forEach(b => b.style.background = ""); | |
| stopFeedAudio(); | |
| }); | |
| feedAudio.addEventListener("error", () => stopFeedAudio()); | |
| feedAudio.addEventListener("pause", () => { | |
| if (playingCard === card) card.querySelector(".song-play-btn").innerHTML = '<svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>'; | |
| }); | |
| feedAudio.addEventListener("play", () => { | |
| if (playingCard === card) { | |
| card.querySelector(".song-play-btn").innerHTML = '<svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><rect x="0" y="0" width="3" height="12"/><rect x="7" y="0" width="3" height="12"/></svg>'; | |
| requestAnimationFrame(updateProgress); | |
| } | |
| }); | |
| } | |
| // ββ Load community feed βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadFeed() { | |
| try { | |
| const client = await getClient(); | |
| const res = await client.predict("/community"); | |
| // Gradio JS client may return {data: [...]} or the value directly | |
| const raw = res?.data?.[0] ?? res?.data ?? res; | |
| const songs = JSON.parse(typeof raw === "string" ? raw : JSON.stringify(raw) || "[]"); | |
| if (songs.length === 0) { | |
| feedEl.innerHTML = '<div class="feed-empty">No community songs yet.<br>Be the first: keep "Share" checked and generate!</div>'; | |
| return; | |
| } | |
| feedEl.innerHTML = ""; | |
| for (const song of songs) { | |
| const card = document.createElement("div"); | |
| card.className = "song-card"; | |
| const dur = song.duration ? `${Math.round(song.duration)}s` : ""; | |
| const when = fmtDate(song.created_at); | |
| // Generate random mini waveform bars (seeded from title for consistency) | |
| const miniBars = Array.from({length: 80}, () => | |
| `<div class="bar" style="height:${15 + Math.random() * 85}%"></div>` | |
| ).join(""); | |
| const thumbHtml = song.thumb_url | |
| ? `<img src="${esc(song.thumb_url)}" style="width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0;" loading="lazy" onerror="this.style.display='none'" />` | |
| : ''; | |
| card.innerHTML = ` | |
| <div class="song-card-top"> | |
| <button class="song-play-btn"><svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg></button> | |
| ${thumbHtml} | |
| <div class="song-title">${esc(song.title || "Untitled")}</div> | |
| <div class="song-tags">${esc(song.tags || "")}</div> | |
| <div class="song-duration">${dur}</div> | |
| <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> | |
| </div> | |
| <div class="mini-waveform">${miniBars}</div> | |
| <div class="song-card-bottom"> | |
| <span class="song-date" title="${esc(fullDate(song.created_at))}">${esc(when)}</span> | |
| <button class="lyrics-toggle" type="button">Lyrics</button> | |
| </div> | |
| <pre class="lyrics-box" hidden></pre> | |
| `; | |
| wireLyricsToggle(card.querySelector(".lyrics-toggle"), card.querySelector(".lyrics-box"), song.lyrics || ""); | |
| card.querySelector(".lyrics-box").addEventListener("click", (e) => e.stopPropagation()); | |
| card.querySelector(".song-play-btn").addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| playFeedSong(card, song.audio_url); | |
| }); | |
| card.querySelector(".mini-waveform").addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); | |
| playFeedSong(card, song.audio_url, ratio); | |
| }); | |
| card.querySelector(".share-icon-btn").addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| generateShareVideo(song, e.currentTarget); | |
| }); | |
| feedEl.appendChild(card); | |
| } | |
| } catch (e) { | |
| console.error("Feed load error:", e); | |
| feedEl.innerHTML = '<div class="feed-empty">Could not load community songs</div>'; | |
| } | |
| } | |
| function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } | |
| // ββ Generate βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| generateBtn.addEventListener("click", async () => { | |
| const desc = descInput.value.trim(); | |
| if (!desc) { setStatus("Describe the song you want to create", "error"); descInput.focus(); return; } | |
| stopFeedAudio(); | |
| setLoading(true); | |
| setStatus(""); | |
| // Show animated placeholder on the left, above output | |
| outputSection.style.display = "none"; | |
| const placeholder = document.createElement("div"); | |
| placeholder.className = "placeholder-card"; | |
| placeholder.id = "gen-placeholder"; | |
| const bars = Array.from({length: 40}, (_, i) => | |
| `<div class="bar" style="height:100%;animation-delay:${(i * 0.06).toFixed(2)}s"></div>` | |
| ).join(""); | |
| placeholder.innerHTML = ` | |
| <div class="placeholder-title">${esc(desc.slice(0, 80))}</div> | |
| <div class="placeholder-sub">Writing lyrics & structured captionβ¦</div> | |
| <div class="fake-waveform">${bars}</div> | |
| `; | |
| outputSection.parentNode.insertBefore(placeholder, outputSection); | |
| const phTitle = placeholder.querySelector(".placeholder-title"); | |
| const phSub = placeholder.querySelector(".placeholder-sub"); | |
| let etaTimer = null; | |
| const stopEta = () => { if (etaTimer) { clearInterval(etaTimer); etaTimer = null; } }; | |
| const startEta = (eta, base) => { | |
| stopEta(); | |
| const t0 = Date.now(); | |
| const tick = () => { | |
| const left = Math.max(0, Math.round(eta - (Date.now() - t0) / 1000)); | |
| phSub.textContent = left > 0 ? `${base} ~${left}s left` : `${base} finishing upβ¦`; | |
| }; | |
| tick(); | |
| etaTimer = setInterval(tick, 1000); | |
| }; | |
| try { | |
| const client = await getClient(); | |
| const job = client.submit("/create", { | |
| description: desc, | |
| audio_duration: parseFloat(durationSelect.value), | |
| seed: -1, | |
| community: communityChk.checked, | |
| instrumental: instrumentalChk.checked, | |
| }); | |
| for await (const msg of job) { | |
| if (msg.type === "status") { | |
| if (msg.queue_size > 0) { | |
| const eta = msg.eta != null ? ` Β· ~${Math.round(msg.eta)}s` : ""; | |
| setStatus(`Queue ${msg.position}/${msg.queue_size}${eta}`); | |
| } | |
| } else if (msg.type === "data") { | |
| const raw = msg.data?.[0]; | |
| if (!raw) continue; | |
| const result = typeof raw === "string" ? JSON.parse(raw) : raw; | |
| if (result.status) { | |
| // Intermediate progress message | |
| if (result.title) phTitle.textContent = result.title; | |
| if (result.status === "generating") { | |
| startEta(Number(result.eta) || 60, result.message || "MiniMax Music 3 is recordingβ¦"); | |
| } else { | |
| phSub.textContent = result.message || "Workingβ¦"; | |
| } | |
| continue; | |
| } | |
| if (!result.audio) continue; | |
| stopEta(); | |
| songTitleEl.textContent = result.title || ""; | |
| songTagsEl.textContent = result.tags || ""; | |
| songDateEl.textContent = fmtDate(result.created_at); | |
| songDateEl.title = fullDate(result.created_at); | |
| wireLyricsToggle(lyricsToggle, lyricsBox, result.lyrics || ""); | |
| const thumbEl = document.getElementById("song-thumb"); | |
| if (result.thumbnail) { thumbEl.src = result.thumbnail; thumbEl.style.display = "block"; } | |
| else { thumbEl.style.display = "none"; } | |
| currentSong = { | |
| title: result.title || "Untitled", | |
| tags: result.tags || "", | |
| thumb_url: result.thumbnail || null, | |
| }; | |
| await loadAudio(result.audio); | |
| if (result.share_error) setStatus(result.share_error, "error"); | |
| // Refresh feed if shared | |
| if (communityChk.checked) loadFeed(); | |
| } | |
| } | |
| } catch (err) { | |
| console.error(err); | |
| const m = (err && err.message) ? err.message.replace(/^Error:\s*/, "") : "Something went wrong"; | |
| setStatus(m, "error"); | |
| } finally { | |
| stopEta(); | |
| placeholder.remove(); | |
| setLoading(false); | |
| } | |
| }); | |
| descInput.addEventListener("keydown", (e) => { | |
| if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); generateBtn.click(); } | |
| }); | |
| // ββ Init βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| getClient().then(() => loadFeed()).catch(console.error); | |
| </script> | |
| </body> | |
| </html> | |