// ♈ 白羊座 · 页面主程序:播放器 / 铭牌 / 导出 / 发布 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) => ``).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 = `⬇ 下载 ${name}(${(r.blob.size / 1048576).toFixed(1)} MB)`; $('#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 = `