Spaces:
Running
Running
Download aries/src/main.js from Ethscriptions/POLS: direct link, hf CLI and curl.
- Browser
- Download file 13.4 kB
-
https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/aries/src/main.js
- Command line
-
hf download hf://spaces/Ethscriptions/POLS/aries/src/main.js
-
curl -L -o main.js https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/aries/src/main.js
13.4 kB
| // ♈ 白羊座 · 页面主程序:播放器 / 铭牌 / 导出 / 发布 | |
| import { TIMELINE } from './timeline.js'; | |
| import { createStage, actIndex } from './stage.js'; | |
| import { drawBadgeCard } from './badge.js'; | |
| import { buildScore, mixTrack, SR } from './score.js'; | |
| import { exportMP4, canWebCodecs } from './export.js'; | |
| import { PLATFORMS, COVER_SIZE, drawCover, countChars } from './publish.js'; | |
| const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)]; | |
| const LEN = TIMELINE.length; | |
| const stage = createStage(); | |
| const opts = { name: '', birthday: '' }; | |
| const fmt = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`; | |
| const toast = msg => { const el = $('#toast'); el.textContent = msg; el.classList.add('on'); clearTimeout(toast.h); toast.h = setTimeout(() => el.classList.remove('on'), 1800); }; | |
| /* ---------- 背景(铭牌卡片 / 封面共用) ---------- */ | |
| function paintBg(ctx, W, H) { | |
| if (stage.sky) { ctx.drawImage(stage.sky.render(12.0, W, H, 'royal', 'ember', .35, { heat: .7, stars: 1, scale: .6 }), 0, 0, W, H); return; } | |
| const g = ctx.createRadialGradient(W / 2, H * .5, 0, W / 2, H * .5, Math.max(W, H) * .8); g.addColorStop(0, '#5a1a2a'); g.addColorStop(1, '#12040a'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); | |
| } | |
| /* ---------- 字体 ---------- */ | |
| const fontsReady = (async () => { | |
| const txt = '白羊座春天从这里出发先锋之证授予火星第一宫ARIES0123456789哈马尔娄宿金羊毛'; | |
| try { await Promise.race([Promise.all(['900 40px "Noto Serif SC"', '700 40px "Noto Serif SC"', '600 40px "Noto Sans SC"', '400 40px "Noto Sans SC"', '700 40px Cinzel', '600 40px Cinzel'].map(f => document.fonts.load(f, txt))), new Promise(r => setTimeout(r, 6000))]); } catch (e) { } | |
| })(); | |
| /* ---------- 音频:配乐 + 旁白 → 一条完整音轨 ---------- */ | |
| let AC = null, track = null, trackP = null; | |
| async function fetchBuf(ac, url) { const r = await fetch(url); if (!r.ok) throw new Error(url); return await ac.decodeAudioData(await r.arrayBuffer()); } | |
| function loadTrack() { | |
| if (trackP) return trackP; | |
| trackP = (async () => { | |
| const dec = new OfflineAudioContext(2, SR, SR); | |
| $('#loadState').textContent = '正在加载旁白与配乐…'; | |
| const vos = await Promise.all(TIMELINE.acts.map(a => fetchBuf(dec, a.vo.src).catch(() => null))); | |
| let score = null; | |
| try { score = await fetchBuf(dec, 'audio/score.mp3'); } | |
| catch (e) { $('#loadState').textContent = '正在现场合成配乐(约 1–3 分钟)…'; score = await buildScore(TIMELINE); } | |
| track = await mixTrack(TIMELINE, score, vos); | |
| $('#loadState').textContent = ''; | |
| return track; | |
| })(); | |
| return trackP; | |
| } | |
| /* ---------- 播放器 ---------- */ | |
| const cv = $('#stage'), ctx = cv.getContext('2d', { alpha: false }); | |
| let orient = (matchMedia('(max-width: 760px) and (orientation: portrait)').matches) ? 'p' : 'l'; | |
| let playing = false, startAt = 0, pos = 0, src = null, clockStart = 0; | |
| function sizeCanvas() { | |
| const box = $('#stageBox'); box.dataset.o = orient; | |
| const r = box.getBoundingClientRect(), dpr = Math.min(2, devicePixelRatio || 1); | |
| const W = Math.round(r.width * dpr), H = Math.round(r.height * dpr); | |
| // 渲染分辨率上限,保证流畅 | |
| const k = Math.min(1, (orient === 'l' ? 1920 : 1080) / W); | |
| cv.width = Math.round(W * k); cv.height = Math.round(H * k); | |
| $$('.o-btn').forEach(b => b.classList.toggle('on', b.dataset.o === orient)); | |
| } | |
| function now() { return playing ? (AC ? AC.currentTime - startAt : (performance.now() - clockStart) / 1000) : pos; } | |
| async function play() { | |
| if (playing) return; | |
| if (pos >= LEN - .05) pos = 0; | |
| AC = AC || new AudioContext({ sampleRate: SR }); | |
| await AC.resume(); | |
| $('#bigPlay').classList.add('hide'); | |
| if (!track) { $('#bigPlay').classList.add('busy'); await loadTrack(); } | |
| src = AC.createBufferSource(); src.buffer = track; src.connect(AC.destination); | |
| startAt = AC.currentTime + .05 - pos; src.start(AC.currentTime + .05, pos); | |
| src.onended = () => { if (playing && now() >= LEN - .2) { playing = false; pos = LEN; ui(); $('#bigPlay').classList.remove('hide'); } }; | |
| playing = true; ui(); | |
| } | |
| function pause() { if (!playing) return; pos = now(); playing = false; try { src && src.stop(); } catch (e) { } src = null; ui(); } | |
| function seek(t) { const was = playing; if (was) pause(); pos = Math.max(0, Math.min(LEN, t)); if (was) play(); else ui(); } | |
| function ui() { $('#playBtn').textContent = playing ? '❚❚' : '▶'; $('#playBtn').setAttribute('aria-label', playing ? '暂停' : '播放'); } | |
| function frame() { | |
| const t = now(); | |
| if (playing && t >= LEN) { pos = LEN; playing = false; ui(); } | |
| stage.draw(ctx, Math.min(t, LEN), cv.width, cv.height, opts); | |
| $('#seek').value = String(t / LEN * 1000); | |
| $('#time').textContent = `${fmt(Math.min(t, LEN))} / ${fmt(LEN)}`; | |
| const i = actIndex(Math.min(t, LEN - .01)); $$('.chap').forEach((c, j) => c.classList.toggle('on', j === i)); | |
| requestAnimationFrame(frame); | |
| } | |
| $('#playBtn').onclick = () => playing ? pause() : play(); | |
| $('#bigPlay').onclick = () => play(); | |
| cv.onclick = () => playing ? pause() : play(); | |
| $('#seek').oninput = e => seek(e.target.value / 1000 * LEN); | |
| $$('.o-btn').forEach(b => b.onclick = () => { orient = b.dataset.o; sizeCanvas(); }); | |
| addEventListener('resize', sizeCanvas); | |
| addEventListener('keydown', e => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.code === 'Space') { e.preventDefault(); playing ? pause() : play(); } if (e.code === 'ArrowRight') seek(now() + 5); if (e.code === 'ArrowLeft') seek(now() - 5); }); | |
| $('#fsBtn').onclick = async () => { | |
| const box = $('#stageBox'); | |
| if (document.fullscreenElement || box.classList.contains('pseudo')) { if (document.fullscreenElement) document.exitFullscreen(); box.classList.remove('pseudo'); document.body.classList.remove('lock'); setTimeout(sizeCanvas, 100); return; } | |
| if (box.requestFullscreen) { try { await box.requestFullscreen(); setTimeout(sizeCanvas, 150); return; } catch (e) { } } | |
| box.classList.add('pseudo'); document.body.classList.add('lock'); setTimeout(sizeCanvas, 50); | |
| }; | |
| document.addEventListener('fullscreenchange', () => setTimeout(sizeCanvas, 150)); | |
| // 章节 | |
| $('#chaps').innerHTML = TIMELINE.acts.map((a, i) => `<button class="chap" data-t="${a.t0}"><b>${String(i + 1).padStart(2, '0')}</b>${a.title}<i>${fmt(a.t0)}</i></button>`).join(''); | |
| $$('.chap').forEach(c => c.onclick = () => { seek(+c.dataset.t + .01); $('#stageBox').scrollIntoView({ behavior: 'smooth', block: 'center' }); }); | |
| /* ---------- 荣耀铭牌 ---------- */ | |
| const bc = $('#badgeCv'), bctx = bc.getContext('2d'); | |
| function drawCard() { bc.width = 540; bc.height = 960; drawBadgeCard(bctx, 540, 960, opts, paintBg); } | |
| function syncName() { | |
| opts.name = $('#nameIn').value.trim().slice(0, 12); opts.birthday = $('#bdIn').value.trim().slice(0, 10); | |
| $('#nameCount').textContent = `${countChars(opts.name)}/12`; drawCard(); | |
| try { localStorage.setItem('aries-badge', JSON.stringify(opts)); } catch (e) { } | |
| } | |
| try { const o = JSON.parse(localStorage.getItem('aries-badge') || '{}'); $('#nameIn').value = o.name || ''; $('#bdIn').value = o.birthday || ''; } catch (e) { } | |
| $('#nameIn').oninput = syncName; $('#bdIn').oninput = syncName; | |
| function dlCanvas(c, name) { c.toBlob(b => { const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); }, 'image/png'); } | |
| $$('[data-card]').forEach(b => b.onclick = () => { | |
| const [W, H] = b.dataset.card === 'wall' ? [1080, 1920] : [1080, 1080]; | |
| const c = document.createElement('canvas'); c.width = W; c.height = H; drawBadgeCard(c.getContext('2d'), W, H, opts, paintBg); | |
| dlCanvas(c, `白羊座先锋铭牌-${opts.name || 'Aries'}-${b.dataset.card === 'wall' ? '壁纸9x16' : '头像1x1'}.png`); | |
| }); | |
| $('#toVideo').onclick = () => { seek(TIMELINE.acts[8].t0 + .01); $('#stageBox').scrollIntoView({ behavior: 'smooth', block: 'center' }); play(); }; | |
| /* ---------- 导出 ---------- */ | |
| let exporting = false, cancel = false; | |
| $('#exSupport').textContent = canWebCodecs() ? '✓ 支持 WebCodecs 高速逐帧导出(画面与预览逐帧一致)' : '⚠ 当前浏览器不支持 WebCodecs,将以实时录制方式导出(需完整播放一遍,建议使用电脑版 Chrome / Edge)'; | |
| $('#exBtn').onclick = async () => { | |
| if (exporting) { cancel = true; return; } | |
| const fps = +$('#exFps').value, o = $('#exOrient').value, res = +$('#exRes').value, subs = $('#exSubs').checked; | |
| const [W, H] = o === 'l' ? [Math.round(res * 16 / 9 / 2) * 2, res] : [res, Math.round(res * 16 / 9 / 2) * 2]; | |
| exporting = true; cancel = false; pause(); | |
| $('#exBtn').textContent = '取消导出'; $('#exBar').style.width = '0%'; $('#exOut').innerHTML = ''; | |
| try { | |
| $('#exState').textContent = '准备音轨…'; await fontsReady; const audio = await loadTrack(); | |
| const t0 = performance.now(); | |
| const r = await exportMP4({ | |
| W, H, fps, length: LEN, audio, isCancelled: () => cancel, | |
| draw: (c, t, w, h) => stage.draw(c, t, w, h, { ...opts, subtitles: subs }), | |
| onProgress: (k, eta) => { $('#exBar').style.width = (k * 100).toFixed(1) + '%'; $('#exState').textContent = `渲染中 ${(k * 100).toFixed(1)}% · 预计剩余 ${fmt(eta)}`; }, | |
| }); | |
| if (!r) { $('#exState').textContent = '已取消'; } | |
| else { | |
| const name = `白羊座-春天从这里出发-${o === 'l' ? '横屏' : '竖屏'}-${H}p${fps}.${r.ext}`; | |
| const url = URL.createObjectURL(r.blob); | |
| $('#exOut').innerHTML = `<a class="btn gold" href="${url}" download="${name}">⬇ 下载 ${name}(${(r.blob.size / 1048576).toFixed(1)} MB)</a>`; | |
| $('#exState').textContent = `完成!用时 ${fmt((performance.now() - t0) / 1000)} · 视频 ${r.videoCodec} · 音频 ${r.audioCodec}`; | |
| window.__lastExport = { size: r.blob.size, name, audio: r.audioCodec }; | |
| } | |
| } catch (e) { console.error(e); $('#exState').textContent = '导出失败:' + (e.message || e); window.__lastExport = { error: String(e) }; } | |
| exporting = false; $('#exBtn').textContent = '开始导出 MP4'; | |
| }; | |
| /* ---------- 发布 ---------- */ | |
| function renderPublish(pid) { | |
| const p = PLATFORMS.find(x => x.id === pid); | |
| $$('.ptab').forEach(b => b.classList.toggle('on', b.dataset.p === pid)); | |
| const desc = typeof p.desc === 'function' ? p.desc() : p.desc; | |
| const tagStr = p.tags.map(t => '#' + t).join(' '); | |
| const tl = countChars(p.title), ok1 = tl <= p.limits.title, ok2 = p.tags.length <= p.limits.tags; | |
| $('#pubBody').innerHTML = ` | |
| <div class="field"><label>标题 <span class="cnt ${ok1 ? 'ok' : 'bad'}">${tl} / ${p.limits.title} 字</span></label><div class="copyrow"><input readonly value="${p.title}"><button class="btn" data-copy="${encodeURIComponent(p.title)}">复制</button></div></div> | |
| <div class="field"><label>描述 <span class="cnt">${countChars(desc)} 字</span></label><div class="copyrow"><textarea readonly rows="${Math.min(14, desc.split('\n').length + 1)}">${desc}</textarea><button class="btn" data-copy="${encodeURIComponent(desc)}">复制</button></div></div> | |
| <div class="field"><label>话题 <span class="cnt ${ok2 ? 'ok' : 'bad'}">${p.tags.length} / ${p.limits.tags === Infinity ? '不限' : p.limits.tags} 个</span></label><div class="copyrow"><input readonly value="${tagStr}"><button class="btn" data-copy="${encodeURIComponent(tagStr)}">复制</button></div></div> | |
| <div class="field"><label>封面</label><div class="covers">${p.covers.map(k => `<figure><img loading="lazy" src="covers/cover-${p.id}-${k}.jpg" alt="${p.name} ${k.replace('x', ':')} 封面"><figcaption>${k.replace('x', ':')}${p.id === 'bilibili' && k === '16x9' ? '(个人空间)' : p.id === 'wechat' && k === '3x4' ? '(主页卡片)' : p.id === 'wechat' && k === '4x3' ? '(分享卡片)' : ''} <a href="covers/cover-${p.id}-${k}.jpg" download>下载</a> · <a href="#" data-gen="${p.id}|${k}">重新生成</a></figcaption></figure>`).join('')}</div></div>`; | |
| $$('[data-copy]').forEach(b => b.onclick = async () => { const s = decodeURIComponent(b.dataset.copy); try { await navigator.clipboard.writeText(s); } catch (e) { const ta = document.createElement('textarea'); ta.value = s; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } toast('已复制'); }); | |
| $$('[data-gen]').forEach(a => a.onclick = async e => { e.preventDefault(); await fontsReady; const [id, k] = a.dataset.gen.split('|'); const [W, H] = COVER_SIZE[k]; const c = document.createElement('canvas'); c.width = W; c.height = H; drawCover(c.getContext('2d'), W, H, id, paintBg); dlCanvas(c, `cover-${id}-${k}.png`); }); | |
| } | |
| $('#ptabs').innerHTML = PLATFORMS.map(p => `<button class="ptab" data-p="${p.id}">${p.name}</button>`).join(''); | |
| $$('.ptab').forEach(b => b.onclick = () => renderPublish(b.dataset.p)); | |
| renderPublish('xhs'); | |
| /* ---------- 启动 ---------- */ | |
| window.ARIES = { exportMP4, stage, TIMELINE, opts, loadTrack, drawCover, paintBg, drawBadgeCard, fontsReady, seek, play, pause }; | |
| sizeCanvas(); | |
| fontsReady.then(() => { syncName(); requestAnimationFrame(frame); }); | |
| if (!stage.hasGL) console.info('使用 Canvas 背景'); | |
| // 空闲时预加载音轨 | |
| setTimeout(() => loadTrack().catch(e => console.warn(e)), 800); | |