POLS / aries /src /main.js
Ethscriptions's picture
aries: 配乐音效、播放器、铭牌导出、MP4 导出、四平台文案与封面、首页入口
e67dc56 verified
Raw History Blame Contribute Delete
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);