// ♉ 金牛座 —— 页面:播放器 / 珍藏卡 / 导出 / 发布包(全新编写) import { FILM } from './script.js'; import { createFilm, actAt } from './film.js'; import { drawCard, drawCardPoster } from './card.js'; import { exportMP4, recordRealtime, hasWebCodecs } from './encoder.js'; import { PLATFORMS, COVER_PX, drawCover, paintBg, charCount } from './release.js'; const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)]; const film = createFilm(), D = FILM.duration; const fmt = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`; const store = { get name() { return localStorage.getItem('taurus.name') || ''; }, get bday() { return localStorage.getItem('taurus.bday') || ''; } }; const opts = () => ({ name: $('#name').value.trim(), birthday: bdayStr() }); /* ================= 播放器 ================= */ const cv = $('#stage'), g = cv.getContext('2d'), wrap = $('#wrap'); let orient = matchMedia('(max-width: 820px) and (orientation: portrait)').matches ? 'portrait' : 'landscape'; let audioCtx = null, track = null, src = null, playing = false, offset = 0, startedAt = 0, subs = true; // 画面时钟 = 声卡时钟 − 输出延迟(蓝牙耳机等可达 0.1~0.3 秒),保证字幕与“听到的”声音同步 const latency = () => audioCtx ? Math.min(.5, (audioCtx.outputLatency || 0) + (audioCtx.baseLatency || 0)) : 0; const now = () => playing ? Math.max(0, Math.min(D, offset + audioCtx.currentTime - startedAt - latency())) : offset; function fit() { wrap.classList.toggle('portrait', orient === 'portrait'); $$('.controls .seg button').forEach(b => b.classList.toggle('on', b.dataset.o === orient)); const r = wrap.getBoundingClientRect(), dpr = Math.min(2, devicePixelRatio || 1); let w = Math.round(r.width * dpr), h = Math.round(r.height * dpr); const k = Math.min(1, 1920 / Math.max(w, h)); w = Math.round(w * k); h = Math.round(h * k); if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; } } async function loadAudio() { audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' }); const buf = await (await fetch('audio/film.mp3?v=2')).arrayBuffer(); track = await new Promise((ok, no) => { const p = audioCtx.decodeAudioData(buf, ok, no); if (p && p.then) p.then(ok, no); }); $('#loading').hidden = true; return track; } const ready = loadAudio().catch(e => { $('#loading').textContent = '音轨载入失败:' + e.message; }); async function play() { await ready; if (!track) return; await audioCtx.resume(); if (offset >= D - .05) offset = 0; src = audioCtx.createBufferSource(); src.buffer = track; src.connect(audioCtx.destination); startedAt = audioCtx.currentTime + .03; src.start(startedAt, offset); playing = true; $('#bigPlay').hidden = true; $('#play').textContent = '❚❚'; } function pause() { if (!playing) return; offset = now(); playing = false; try { src.stop(); } catch (e) {} src = null; $('#play').textContent = '▶'; } function seek(t) { const was = playing; pause(); offset = Math.max(0, Math.min(D, t)); if (was) play(); } $('#play').onclick = () => playing ? pause() : play(); $('#bigPlay').onclick = play; cv.onclick = () => playing ? pause() : play(); $('#cc').onclick = e => { subs = !subs; e.currentTarget.classList.toggle('on', subs); }; $$('.controls .seg button').forEach(b => b.onclick = () => { orient = b.dataset.o; fit(); }); $('#fs').onclick = () => { const el = wrap; (document.fullscreenElement ? document.exitFullscreen() : (el.requestFullscreen || el.webkitRequestFullscreen || (() => {})).call(el)); }; document.addEventListener('keydown', e => { if (e.target.tagName === 'INPUT') return; if (e.code === 'Space') { e.preventDefault(); playing ? pause() : play(); } if (e.code === 'ArrowRight') seek(now() + 5); if (e.code === 'ArrowLeft') seek(now() - 5); }); // 进度条与章节 const bar = $('#bar'); FILM.acts.forEach((A, i) => { if (i) { const m = document.createElement('i'); m.style.left = (A.start / D * 100) + '%'; $('#marks').append(m); } const li = document.createElement('li'); li.innerHTML = `${String(i + 1).padStart(2, '0')} · ${fmt(A.start)}${A.title}`; li.onclick = () => { seek(A.start + .01); if (!playing) play(); }; $('#chapters').append(li); }); const scrub = e => { const r = bar.getBoundingClientRect(); seek((e.clientX - r.left) / r.width * D); }; bar.addEventListener('pointerdown', e => { bar.setPointerCapture(e.pointerId); scrub(e); bar.onpointermove = scrub; }); bar.addEventListener('pointerup', () => bar.onpointermove = null); let lastAct = -1, lastKey = ''; function frame() { fit(); const t = now(); if (playing && t >= D) { pause(); offset = D; } const o = opts(), key = [t.toFixed(3), cv.width, cv.height, o.name, o.birthday, subs].join('|'); if (key !== lastKey) { lastKey = key; film.draw(g, t, cv.width, cv.height, { ...o, subtitles: subs }); } $('#fill').style.width = (t / D * 100) + '%'; $('#time').textContent = `${fmt(t)} / ${fmt(D)}`; const a = actAt(t); if (a !== lastAct) { lastAct = a; $$('#chapters li').forEach((li, i) => li.classList.toggle('on', i === a)); } requestAnimationFrame(frame); } addEventListener('resize', fit); /* ================= 珍藏卡 ================= */ const bm = $('#bm'), bd = $('#bd'); function fillDays() { const m = +bm.value, v = bd.value; bd.innerHTML = ''; const [a, b] = m === 4 ? [20, 30] : m === 5 ? [1, 20] : [0, -1]; for (let d = a; d <= b; d++) bd.add(new Option(d, d)); if ([...bd.options].some(o => o.value === v)) bd.value = v; } function bdayStr() { return bm.value && bd.value ? `${bm.value}月${bd.value}日` : ''; } bm.onchange = () => { fillDays(); save(); }; bd.onchange = save; $('#name').oninput = save; function save() { localStorage.setItem('taurus.name', $('#name').value.trim()); localStorage.setItem('taurus.bday', bm.value && bd.value ? `${bm.value}-${bd.value}` : ''); $('#bhint').textContent = bdayStr() ? `已刻上生日:${bdayStr()} · 欢迎回家,金牛座` : '金牛座:4 月 20 日 — 5 月 20 日'; } $('#name').value = store.name; if (store.bday) { const [m, d] = store.bday.split('-'); bm.value = m; fillDays(); bd.value = d; } else fillDays(); save(); const cardCv = $('#cardView'), cg = cardCv.getContext('2d'); let showBack = false, flipK = 1, cardT0 = performance.now(); $('#flipBtn').onclick = () => { showBack = !showBack; $('#flipBtn').textContent = showBack ? '翻回正面' : '翻转看看背面'; }; let cardVisible = false, cardDrawn = false, lastCard = 0, cardBg = null; new IntersectionObserver(es => es.forEach(e => cardVisible = e.isIntersecting)).observe(cardCv); function cardLoop() { if ((!cardVisible || document.hidden) && cardDrawn) return requestAnimationFrame(cardLoop); cardDrawn = true; const nowMs = performance.now(); if (cardDrawn > 1 && nowMs - lastCard < 33) return requestAnimationFrame(cardLoop); lastCard = nowMs; cardDrawn = 2; const t = (nowMs - cardT0) / 1000, W = cardCv.width, H = cardCv.height; flipK += ((showBack ? 0 : 1) - flipK) * .2; if (!cardBg) { cardBg = document.createElement('canvas'); cardBg.width = W; cardBg.height = H; paintBg(cardBg.getContext('2d'), W, H, 1); } cg.drawImage(cardBg, 0, 0); drawCard(cg, W / 2, H / 2, H * .86, { t, flip: flipK, fields: 1, shine: ((t * .22) % 1.4) / 1.4, holo: .8, stamp: 1, holder: opts().name, birthday: opts().birthday, tilt: Math.sin(t * .7) * .015 }); requestAnimationFrame(cardLoop); } const POSTER = { '9:16': [1080, 1920], '1:1': [1440, 1440], '3:4': [1200, 1600] }; $$('.btns button').forEach(b => b.onclick = () => { const [W, H] = POSTER[b.dataset.r], c = document.createElement('canvas'); c.width = W; c.height = H; drawCardPoster(c.getContext('2d'), W, H, opts(), (g2, w, h) => paintBg(g2, w, h, 1)); c.toBlob(bl => download(bl, `金牛座鎏金珍藏卡-${opts().name || 'Taurus'}-${b.dataset.r.replace(':', 'x')}.png`), 'image/png'); }); function download(blob, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 60000); } /* ================= 导出 ================= */ const pick = id => $(`${id} button.on`).dataset.v; ['#exFps', '#exO', '#exR', '#exRange'].forEach(id => $$(`${id} button`).forEach(b => b.onclick = () => { $$(`${id} button`).forEach(x => x.classList.toggle('on', x === b)); })); let ctl = null; async function runExport(cfg = {}) { await ready; if (!track) throw new Error('音轨未载入'); const fps = +(cfg.fps ?? pick('#exFps')), o = cfg.orient ?? pick('#exO'), res = +(cfg.res ?? pick('#exR')); let from = 0, to = D; if (cfg.from !== undefined) { from = cfg.from; to = cfg.to; } else if (pick('#exRange') === 'act') { const A = FILM.acts[actAt(now())]; from = A.start; to = A.end; } ctl = new AbortController(); $('#exGo').disabled = true; $('#exStop').hidden = false; $('#exOut').innerHTML = ''; const o2 = opts(), job = { fps, orient: o, res, from, to, audio: track, signal: ctl.signal, draw: (g2, t, W, H) => film.draw(g2, t, W, H, o2), onProgress: (k, m) => { $('#exFill').style.width = (k * 100).toFixed(1) + '%'; $('#exMsg').textContent = m; } }; try { const r = hasWebCodecs() ? await exportMP4(job) : await recordRealtime(job), ext = r.ext || 'mp4'; const name = `金牛座-慢慢来-${o === 'portrait' ? '竖屏' : '横屏'}-${res}p${fps}.${ext}`, url = URL.createObjectURL(r.blob); $('#exOut').innerHTML = `⬇ 下载 ${name}(${(r.blob.size / 1048576).toFixed(1)} MB)`; $('#exMsg').textContent = `完成:${r.W}×${r.H} · ${r.fps} fps · 音频 ${r.audio}`; window.__lastExport = r; return r; } catch (e) { $('#exMsg').textContent = e.name === 'AbortError' ? '已取消' : '导出失败:' + e.message; throw e; } finally { $('#exGo').disabled = false; $('#exStop').hidden = true; } } $('#exGo').onclick = () => runExport().catch(() => {}); $('#exStop').onclick = () => ctl?.abort(); if (!hasWebCodecs()) $('#exMsg').textContent = '当前浏览器不支持逐帧编码,将以实时录制方式导出(耗时等于片长)。推荐使用电脑版 Chrome / Edge。'; /* ================= 发布包 ================= */ const esc = s => s.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); function showPlatform(id) { const p = PLATFORMS.find(x => x.id === id); $$('#tabs button').forEach(b => b.classList.toggle('on', b.dataset.id === id)); const tags = p.tags.map(t => '#' + t).join(' '), n = charCount(p.title), lim = p.tagMax > 50 ? '不限' : p.tagMax; $('#pub').innerHTML = `

标题 ${n} / ${p.titleMax} 字

${esc(p.title)}

简介

${esc(p.desc)}

话题 ${p.tags.length} / ${lim}

${esc(tags)}
${p.covers.map(r => { const f = `covers/cover-${p.id}-${r.replace(':', 'x')}.jpg`, [w, h] = COVER_PX[r]; return `
${p.name}封面 ${r}
${r} · ${w}×${h}下载
`; }).join('')}
`; $$('#pub .copy').forEach(b => b.onclick = async () => { const v = { title: p.title, desc: p.desc, tags }[b.dataset.c]; try { await navigator.clipboard.writeText(v); b.textContent = '已复制'; } catch (e) { b.textContent = '请手动复制'; } setTimeout(() => b.textContent = '复制', 1500); }); } PLATFORMS.forEach(p => { const b = document.createElement('button'); b.textContent = p.name; b.dataset.id = p.id; b.onclick = () => showPlatform(p.id); $('#tabs').append(b); }); showPlatform('xhs'); /* ================= 启动 ================= */ (async () => { try { await Promise.race([Promise.all(['900 40px "Noto Serif SC"', '700 40px "Noto Serif SC"', '600 40px "Noto Sans SC"', '700 40px "Cormorant Garamond"'].map(f => document.fonts.load(f, '金牛座慢慢来TAURUS0123'))), new Promise(r => setTimeout(r, 2500))]); } catch (e) {} fit(); film.backdrop.warm(); frame(); cardLoop(); })(); window.TAURUS = { FILM, film, runExport, drawCover, PLATFORMS, COVER_PX, charCount, ready, seek, play, pause, now: () => now() };