no message
This commit is contained in:
529
player.html
529
player.html
@@ -29,9 +29,10 @@
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Roboto, sans-serif;
|
||||
background: #1a82ea;
|
||||
/* background: #1a82ea; */
|
||||
color: var(--text-main);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -46,6 +47,8 @@
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
background: #1a82ea;
|
||||
|
||||
}
|
||||
|
||||
.bg-image {
|
||||
@@ -435,10 +438,18 @@
|
||||
scroll-behavior: smooth;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
|
||||
.lyrics-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.lyrics-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.lyric-line {
|
||||
@@ -497,6 +508,8 @@
|
||||
order: 3;
|
||||
width: 100%;
|
||||
padding: 20px 25px 40px;
|
||||
padding-bottom: calc(40px + env(safe-area-inset-bottom));
|
||||
padding-bottom: calc(40px + constant(safe-area-inset-bottom));
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
@@ -714,8 +727,25 @@
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recBlink {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||||
<script src="/static/js/ffmpeg.min.js"></script>
|
||||
<script src="/static/js/html2canvas.min.js"></script>
|
||||
<script src="/static/js/html-to-image.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -725,6 +755,9 @@
|
||||
<div class="loader-text">LOADING...</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 背景层 -->
|
||||
<div class="bg-layer">
|
||||
<div class="bg-image" id="bgImg"></div>
|
||||
@@ -732,7 +765,6 @@
|
||||
<div class="particles" id="particles"></div>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 歌名信息 -->
|
||||
<div class="header-info">
|
||||
<div class="header-title" id="songTitle">...</div>
|
||||
@@ -838,11 +870,24 @@
|
||||
<path d="M15 15v2h-2v-2h2zm0-8v2h-2V7h2zm-4 4v2H9v-2h2zm0-4v2H9V7h2zm-4 4v2H5v-2h2zm0-4v2H5V7h2zm12 12H3V3h18v16z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-side" id="recToggleBtn" onclick="toggleRecording()" title="录制开关">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="recIndicator" style="position:fixed;top:12px;right:12px;z-index:999;display:none;align-items:center;gap:6px;background:rgba(0,0,0,0.4);padding:6px 10px;border-radius:16px;color:#fff;backdrop-filter:blur(6px)">
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#ff3b30;box-shadow:0 0 10px #ff3b30;animation:recBlink 1s infinite"></span>
|
||||
<span style="font-size:12px;letter-spacing:1px">REC</span>
|
||||
</div>
|
||||
|
||||
<audio id="audio" crossorigin="anonymous"></audio>
|
||||
<video id="tabCaptureVideo" playsinline muted style="display:none"></video>
|
||||
|
||||
<script>
|
||||
// 获取参数
|
||||
@@ -875,7 +920,10 @@
|
||||
totalTime: document.getElementById('totalTime'),
|
||||
lyricsMode: document.getElementById('lyricsMode'),
|
||||
lyricsBox: document.getElementById('lyricsBox'),
|
||||
miniLyrics: document.getElementById('miniLyrics')
|
||||
miniLyrics: document.getElementById('miniLyrics'),
|
||||
recIndicator: document.getElementById('recIndicator'),
|
||||
recToggleBtn: document.getElementById('recToggleBtn'),
|
||||
tabVideo: document.getElementById('tabCaptureVideo')
|
||||
};
|
||||
|
||||
// 初始化
|
||||
@@ -912,10 +960,22 @@
|
||||
setTimeout(() => els.loader.remove(), 600);
|
||||
}
|
||||
|
||||
function renderUI(data) {
|
||||
async function renderUI(data) {
|
||||
els.songTitle.innerText = data.title;
|
||||
|
||||
// 预加载图片并转换为 Blob URL (解决跨域和缓存问题)
|
||||
try {
|
||||
const imgRes = await fetch(data.img);
|
||||
const imgBlob = await imgRes.blob();
|
||||
const imgUrl = URL.createObjectURL(imgBlob);
|
||||
els.coverImg.src = imgUrl;
|
||||
els.bgImg.style.backgroundImage = `url('${imgUrl}')`;
|
||||
} catch (e) {
|
||||
console.warn('图片缓存失败,使用原始链接', e);
|
||||
els.coverImg.src = data.img;
|
||||
els.bgImg.style.backgroundImage = `url('${data.img}')`;
|
||||
}
|
||||
|
||||
els.audio.src = data.playurl;
|
||||
|
||||
// 图片加载后显示
|
||||
@@ -949,6 +1009,7 @@
|
||||
els.audio.addEventListener('play', () => {
|
||||
isPlaying = true;
|
||||
updatePlayState();
|
||||
if (recordEnabled) startSilentMV();
|
||||
});
|
||||
|
||||
els.audio.addEventListener('pause', () => {
|
||||
@@ -956,6 +1017,45 @@
|
||||
updatePlayState();
|
||||
});
|
||||
|
||||
els.audio.addEventListener('ended', () => {
|
||||
stopSilentMV();
|
||||
});
|
||||
|
||||
function downloadFile(name, blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function transcodeToMp4(webmBlob, title) {
|
||||
try {
|
||||
if (!window.FFmpeg || !FFmpeg.createFFmpeg) {
|
||||
downloadFile(`${title}.webm`, webmBlob);
|
||||
msg('已保存 WebM');
|
||||
return;
|
||||
}
|
||||
const { createFFmpeg, fetchFile } = FFmpeg;
|
||||
const ffmpeg = createFFmpeg({ log: false, corePath: 'https://unpkg.com/@ffmpeg/core@0.12.15/dist/ffmpeg-core.js' });
|
||||
msg('正在转码为 MP4');
|
||||
await ffmpeg.load();
|
||||
ffmpeg.FS('writeFile', 'input.webm', await fetchFile(webmBlob));
|
||||
await ffmpeg.run('-i', 'input.webm', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', 'output.mp4');
|
||||
const data = ffmpeg.FS('readFile', 'output.mp4');
|
||||
const mp4Blob = new Blob([data.buffer], { type: 'video/mp4' });
|
||||
downloadFile(`${title}.mp4`, mp4Blob);
|
||||
msg('MP4 已生成');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
downloadFile(`${title}.webm`, webmBlob);
|
||||
msg('转码失败,已保存 WebM');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayState() {
|
||||
if (isPlaying) {
|
||||
els.body.classList.add('is-playing');
|
||||
@@ -1264,6 +1364,423 @@
|
||||
setTimeout(() => div.remove(), 2000);
|
||||
}
|
||||
|
||||
let recordEnabled = false;
|
||||
let mvCanvas = null;
|
||||
let mvCtx = null;
|
||||
let mvRecorder = null;
|
||||
let mvChunks = [];
|
||||
let mvRenderTimer = null;
|
||||
let isMVRecording = false;
|
||||
let mvStream = null;
|
||||
let mvAudioTrack = null;
|
||||
let mvRafId = 0;
|
||||
let displayStream = null;
|
||||
|
||||
els.viewWrapper = document.querySelector('.view-wrapper');
|
||||
els.captureTarget = document.querySelector('.view-wrapper');
|
||||
const isMobile = /Mobile|Android|iP(hone|od|ad)|IEMobile|BlackBerry|Opera Mini/i.test(navigator.userAgent) || (navigator.maxTouchPoints > 1);
|
||||
|
||||
function buildSVGFromElement(el, w, h) {
|
||||
const styles = Array.from(document.querySelectorAll('style')).map(s => s.textContent).join('\n');
|
||||
const html = el.outerHTML;
|
||||
const svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${w}\" height=\"${h}\"><foreignObject width=\"100%\" height=\"100%\"><div xmlns=\"http://www.w3.org/1999/xhtml\"><style>${styles}</style>${html}</div></foreignObject></svg>`;
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
// 手动绘制 Canvas 帧(高性能)
|
||||
function drawCanvasFrame() {
|
||||
if (!mvCtx || !mvCanvas) return;
|
||||
const w = mvCanvas.width;
|
||||
const h = mvCanvas.height;
|
||||
const ctx = mvCtx;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// --- 1. 绘制背景 ---
|
||||
// 模拟 .bg-image 的呼吸效果
|
||||
ctx.save();
|
||||
const scale = isPlaying ? (1.1 + Math.sin(Date.now() / 2000) * 0.1) : 1.1; // 呼吸动画
|
||||
|
||||
if (els.coverImg.complete && els.coverImg.naturalWidth > 0) {
|
||||
// 绘制模糊背景图
|
||||
// 为了性能,不使用 context.filter (部分浏览器支持不佳且慢),而是假设背景是暗色或绘制蒙版
|
||||
// 这里简单绘制放大的图片并覆盖半透明蒙层
|
||||
const imgW = w * scale;
|
||||
const imgH = h * scale;
|
||||
const x = (w - imgW) / 2;
|
||||
const y = (h - imgH) / 2;
|
||||
|
||||
ctx.drawImage(els.coverImg, x, y, imgW, imgH);
|
||||
} else {
|
||||
ctx.fillStyle = '#222';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
// 绘制蒙版 .bg-mask
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, h);
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.1)');
|
||||
gradient.addColorStop(0.5, 'rgba(0, 0, 0, 0.5)');
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.9)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
|
||||
// --- 2. 绘制标题 ---
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
||||
ctx.shadowBlur = 10 * dpr;
|
||||
ctx.shadowOffsetY = 4 * dpr;
|
||||
ctx.font = `bold ${22 * dpr}px sans-serif`;
|
||||
// 顶部留出空间,大约 15% 高度处
|
||||
ctx.fillText(els.songTitle.innerText, w / 2, h * 0.15);
|
||||
|
||||
// 绘制 Mini Lyrics (如果不在歌词模式)
|
||||
if (!isLyricView) {
|
||||
// 简单绘制一行当前歌词
|
||||
const activeLine = document.querySelector('.mini-line.active');
|
||||
if (activeLine) {
|
||||
ctx.font = `bold ${16 * dpr}px sans-serif`;
|
||||
ctx.fillStyle = '#d4af37'; // var(--theme-color)
|
||||
ctx.fillText(activeLine.innerText, w / 2, h * 0.22);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// --- 3. 绘制中间内容 (唱片 或 歌词) ---
|
||||
const centerX = w / 2;
|
||||
const centerY = h * 0.5; // 垂直居中
|
||||
|
||||
if (!isLyricView) {
|
||||
// === 唱片模式 ===
|
||||
const discRadius = Math.min(w, h) * 0.35;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(centerX, centerY);
|
||||
|
||||
// 旋转动画
|
||||
if (isPlaying) {
|
||||
const angle = (Date.now() / 5000) * 360 % 360;
|
||||
ctx.rotate(angle * Math.PI / 180);
|
||||
} else {
|
||||
// 暂停时不旋转,保持当前角度 (简化处理,直接重置或保持0)
|
||||
// 为了平滑,最好记录上次角度,这里简化为0
|
||||
}
|
||||
|
||||
// 唱片外圈 (黑色纹理)
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, discRadius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 6 * dpr;
|
||||
ctx.strokeStyle = '#080808';
|
||||
ctx.stroke();
|
||||
|
||||
// 唱片纹理 (简单模拟)
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, discRadius * 0.9, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = '#222';
|
||||
ctx.lineWidth = 2 * dpr;
|
||||
ctx.stroke();
|
||||
|
||||
// 封面图片 (圆形裁切)
|
||||
const coverRadius = discRadius * 0.65;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, coverRadius, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
// 封面跳动效果
|
||||
const beatScale = isPlaying ? (1 + Math.sin(Date.now() / 200) * 0.01) : 1;
|
||||
ctx.scale(beatScale, beatScale);
|
||||
if (els.coverImg.complete) {
|
||||
ctx.drawImage(els.coverImg, -coverRadius, -coverRadius, coverRadius * 2, coverRadius * 2);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
ctx.restore(); // 结束唱片旋转
|
||||
|
||||
// 绘制唱臂 (Needle)
|
||||
// 唱臂是固定在顶部的,不随唱片旋转,但随播放状态旋转
|
||||
ctx.save();
|
||||
// 唱臂支点位置:唱片上方
|
||||
const pivotX = centerX;
|
||||
const pivotY = centerY - discRadius - (40 * dpr);
|
||||
|
||||
ctx.translate(pivotX, pivotY); // 移动到支点附近
|
||||
|
||||
// 唱臂旋转角度:播放时 -5度,暂停时 -35度
|
||||
// 这里坐标系不同,需要调整
|
||||
// 假设 pivot 在上方,针头向下
|
||||
const needleAngle = isPlaying ? -5 : -35;
|
||||
// 调整绘制原点偏移
|
||||
ctx.translate(0, -50 * dpr); // 模拟 CSS 的 transform-origin
|
||||
ctx.rotate(needleAngle * Math.PI / 180);
|
||||
|
||||
// 绘制唱臂杆
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.fillRect(-4 * dpr, 0, 8 * dpr, 140 * dpr);
|
||||
|
||||
// 绘制唱头
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(-12 * dpr, 140 * dpr, 24 * dpr, 38 * dpr);
|
||||
|
||||
// 绘制支点
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 20 * dpr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#e0e0e0';
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
|
||||
} else {
|
||||
// === 歌词模式 ===
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const boxH = h * 0.6;
|
||||
const startY = (h - boxH) / 2;
|
||||
|
||||
// 简单的歌词绘制逻辑:绘制当前句及前后几句
|
||||
// 找到 active 的歌词
|
||||
let activeIndex = lyricLines.findIndex(l => Math.abs(l.time - els.audio.currentTime) < 0.5);
|
||||
// 如果没找到精确匹配,找最近的一个过去的时间
|
||||
if (activeIndex === -1) {
|
||||
activeIndex = lyricLines.filter(l => l.time <= els.audio.currentTime).length - 1;
|
||||
}
|
||||
if (activeIndex === -1) activeIndex = 0;
|
||||
|
||||
const lineHeight = 40 * dpr;
|
||||
const maxLines = 7; // 显示行数
|
||||
|
||||
for (let i = -3; i <= 3; i++) {
|
||||
const idx = activeIndex + i;
|
||||
if (idx >= 0 && idx < lyricLines.length) {
|
||||
const line = lyricLines[idx];
|
||||
const y = centerY + i * lineHeight;
|
||||
|
||||
if (i === 0) {
|
||||
// 当前句
|
||||
ctx.font = `bold ${22 * dpr}px sans-serif`;
|
||||
ctx.fillStyle = '#d4af37';
|
||||
ctx.shadowColor = 'rgba(212, 175, 55, 0.4)';
|
||||
ctx.shadowBlur = 10;
|
||||
} else {
|
||||
// 其他句
|
||||
ctx.font = `${16 * dpr}px sans-serif`;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.fillText(line.el.innerText, centerX, y);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// --- 4. 绘制底部进度条 ---
|
||||
const barY = h * 0.85;
|
||||
const barWidth = w * 0.8;
|
||||
const barX = (w - barWidth) / 2;
|
||||
|
||||
// 总进度条背景
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(barX, barY, barWidth, 4 * dpr, 2 * dpr);
|
||||
ctx.fill();
|
||||
|
||||
// 当前进度
|
||||
const duration = els.audio.duration || 1;
|
||||
const progress = els.audio.currentTime / duration;
|
||||
const currentBarWidth = barWidth * progress;
|
||||
|
||||
// 进度填充
|
||||
const gradBar = ctx.createLinearGradient(barX, 0, barX + currentBarWidth, 0);
|
||||
gradBar.addColorStop(0, '#fff');
|
||||
gradBar.addColorStop(1, '#d4af37');
|
||||
ctx.fillStyle = gradBar;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(barX, barY, currentBarWidth, 4 * dpr, 2 * dpr);
|
||||
ctx.fill();
|
||||
|
||||
// 进度点
|
||||
ctx.beginPath();
|
||||
ctx.arc(barX + currentBarWidth, barY + 2 * dpr, 6 * dpr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.shadowColor = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.shadowBlur = 10 * dpr;
|
||||
ctx.fill();
|
||||
|
||||
// 时间文字
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.font = `${11 * dpr}px monospace`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(els.currTime.innerText, barX, barY - 15 * dpr);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(els.totalTime.innerText, barX + barWidth, barY - 15 * dpr);
|
||||
|
||||
// --- 5. 绘制音频波形 (可选) ---
|
||||
// 简单模拟
|
||||
if (isPlaying) {
|
||||
ctx.save();
|
||||
ctx.translate(centerX, barY - 40 * dpr);
|
||||
ctx.fillStyle = '#fff';
|
||||
for (let i = -2; i <= 2; i++) {
|
||||
const hWave = Math.random() * 16 * dpr;
|
||||
ctx.fillRect(i * 6 * dpr, -hWave, 3 * dpr, hWave);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function startSilentMV() {
|
||||
if (!recordEnabled || isMVRecording) return;
|
||||
// if (isMobile) { msg('移动端暂不支持录制'); return; }
|
||||
|
||||
// 初始化 Canvas (基于 .main-container 大小)
|
||||
// 注意:这里使用固定比例或窗口大小,为了清晰度,使用 devicePixelRatio
|
||||
const rect = els.mainContainer ? els.mainContainer.getBoundingClientRect() : document.body.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// 创建新的 Canvas 实例用于录制
|
||||
mvCanvas = document.createElement('canvas');
|
||||
mvCtx = mvCanvas.getContext('2d');
|
||||
// 设置 Canvas 尺寸
|
||||
mvCanvas.width = rect.width * dpr;
|
||||
mvCanvas.height = rect.height * dpr;
|
||||
|
||||
// 启动渲染循环:html-to-image 截图
|
||||
// 目标帧率 120 FPS,不再使用 setTimeout 限流,而是全力渲染
|
||||
const targetFPS = 120;
|
||||
|
||||
async function renderLoop() {
|
||||
if (!isMVRecording) return;
|
||||
|
||||
// 标记开始处理
|
||||
const beginTime = Date.now();
|
||||
|
||||
try {
|
||||
const source = document.querySelector('.main-container');
|
||||
if (source) {
|
||||
// 恢复高清录制,限制最大 dpr 为 2 以平衡性能
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const canvas = await htmlToImage.toCanvas(source, {
|
||||
backgroundColor: null,
|
||||
pixelRatio: dpr,
|
||||
skipAutoScale: true,
|
||||
cacheBust: false,
|
||||
fontEmbedCSS: '', // 禁用字体嵌入,防止崩溃
|
||||
filter: (node) => {
|
||||
// 过滤无效图片防止崩溃
|
||||
if (node.tagName === 'IMG' && (!node.src || node.src === window.location.href)) return false;
|
||||
return true;
|
||||
},
|
||||
style: {
|
||||
transform: 'translateZ(0)'
|
||||
}
|
||||
});
|
||||
|
||||
// 将捕捉到的画面绘制到录制画布上
|
||||
mvCtx.clearRect(0, 0, mvCanvas.width, mvCanvas.height);
|
||||
mvCtx.drawImage(canvas, 0, 0, mvCanvas.width, mvCanvas.height);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Frame capture error:', e);
|
||||
}
|
||||
|
||||
// 不再使用 setTimeout 进行延时,避免 JS 定时器精度问题导致的抖动
|
||||
// 直接请求下一帧,让浏览器决定最佳时机(通常是 60Hz,如果设备支持高刷则更高)
|
||||
// 这样可以消除人为引入的卡顿
|
||||
if (isMVRecording) {
|
||||
mvRafId = requestAnimationFrame(renderLoop);
|
||||
}
|
||||
}
|
||||
mvRafId = requestAnimationFrame(renderLoop);
|
||||
|
||||
mvStream = mvCanvas.captureStream(targetFPS);
|
||||
|
||||
// 添加音频轨道
|
||||
const audioEl = document.getElementById('audio');
|
||||
if (audioEl) {
|
||||
try {
|
||||
let audioStream;
|
||||
if (audioEl.captureStream) {
|
||||
audioStream = audioEl.captureStream();
|
||||
} else if (audioEl.mozCaptureStream) {
|
||||
audioStream = audioEl.mozCaptureStream();
|
||||
}
|
||||
|
||||
if (audioStream) {
|
||||
const audioTrack = audioStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
mvStream.addTrack(audioTrack);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Audio capture failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
mvChunks = [];
|
||||
const optsList = [
|
||||
{ mimeType: 'video/mp4;codecs=h264,aac' },
|
||||
{ mimeType: 'video/mp4' },
|
||||
{ mimeType: 'video/webm;codecs=vp9,opus' },
|
||||
{ mimeType: 'video/webm;codecs=vp8,opus' },
|
||||
{ mimeType: 'video/webm' }
|
||||
];
|
||||
let opts = {};
|
||||
for (const o of optsList) {
|
||||
if (MediaRecorder.isTypeSupported(o.mimeType)) {
|
||||
opts = {
|
||||
...o,
|
||||
videoBitsPerSecond: 25000000 // 25 Mbps 高码率,确保视频高清
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
mvRecorder = new MediaRecorder(mvStream, opts);
|
||||
mvRecorder.ondataavailable = e => { if (e.data && e.data.size) mvChunks.push(e.data); };
|
||||
mvRecorder.onstop = async () => {
|
||||
const title = (musicData.title || 'mv').replace(/\s+/g, '_');
|
||||
const blob = new Blob(mvChunks, { type: mvRecorder.mimeType || 'video/webm' });
|
||||
const type = mvRecorder.mimeType || 'video/webm';
|
||||
if (/mp4/i.test(type)) {
|
||||
downloadFile(`${title}.mp4`, blob);
|
||||
} else {
|
||||
await transcodeToMp4(blob, title);
|
||||
}
|
||||
};
|
||||
mvRecorder.start();
|
||||
isMVRecording = true;
|
||||
if (els.recIndicator) els.recIndicator.style.display = 'flex';
|
||||
if (els.recToggleBtn) els.recToggleBtn.style.color = '#ff3b30';
|
||||
}
|
||||
|
||||
function stopSilentMV() {
|
||||
if (!isMVRecording) return;
|
||||
try { mvRecorder && mvRecorder.stop(); } catch (_) { }
|
||||
try { if (mvStream) mvStream.getTracks().forEach(t => t.stop()); } catch (_) { }
|
||||
try { if (displayStream) displayStream.getTracks().forEach(t => t.stop()); } catch (_) { }
|
||||
if (mvRafId) cancelAnimationFrame(mvRafId);
|
||||
mvRafId = 0;
|
||||
clearInterval(mvRenderTimer);
|
||||
mvRenderTimer = null;
|
||||
isMVRecording = false;
|
||||
if (els.recIndicator) els.recIndicator.style.display = 'none';
|
||||
if (els.recToggleBtn) els.recToggleBtn.style.color = '';
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
recordEnabled = !recordEnabled;
|
||||
if (recordEnabled && isPlaying) startSilentMV();
|
||||
if (!recordEnabled && isMVRecording) stopSilentMV();
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
1
static/js/ffmpeg.min.js
vendored
Normal file
1
static/js/ffmpeg.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FFmpegWASM=t():e.FFmpegWASM=t()}(self,()=>(()=>{"use strict";var i={m:{},d:(e,t)=>{for(var s in t)i.o(t,s)&&!i.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},u:e=>e+".ffmpeg.js"};i.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.g.importScripts&&(e=i.g.location+"");var e,t=i.g.document;if(!e&&t&&!(e=t.currentScript?t.currentScript.src:e)){var s=t.getElementsByTagName("script");if(s.length)for(var a=s.length-1;-1<a&&!e;)e=s[a--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),i.p=e,i.b=document.baseURI||self.location.href;var r,o,n,t={};i.r(t),i.d(t,{FFFSType:()=>o,FFmpeg:()=>c}),(n=r=r||{}).LOAD="LOAD",n.EXEC="EXEC",n.FFPROBE="FFPROBE",n.WRITE_FILE="WRITE_FILE",n.READ_FILE="READ_FILE",n.DELETE_FILE="DELETE_FILE",n.RENAME="RENAME",n.CREATE_DIR="CREATE_DIR",n.LIST_DIR="LIST_DIR",n.DELETE_DIR="DELETE_DIR",n.ERROR="ERROR",n.DOWNLOAD="DOWNLOAD",n.PROGRESS="PROGRESS",n.LOG="LOG",n.MOUNT="MOUNT",n.UNMOUNT="UNMOUNT";const E=(()=>{let e=0;return()=>e++})(),p=(new Error("unknown message type"),new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first")),d=new Error("called FFmpeg.terminate()");new Error("failed to import ffmpeg-core.js");class c{#e=null;#t={};#s={};#r=[];#a=[];loaded=!1;#o=()=>{this.#e&&(this.#e.onmessage=({data:{id:e,type:t,data:s}})=>{switch(t){case r.LOAD:this.loaded=!0,this.#t[e](s);break;case r.MOUNT:case r.UNMOUNT:case r.EXEC:case r.FFPROBE:case r.WRITE_FILE:case r.READ_FILE:case r.DELETE_FILE:case r.RENAME:case r.CREATE_DIR:case r.LIST_DIR:case r.DELETE_DIR:this.#t[e](s);break;case r.LOG:this.#r.forEach(e=>e(s));break;case r.PROGRESS:this.#a.forEach(e=>e(s));break;case r.ERROR:this.#s[e](s)}delete this.#t[e],delete this.#s[e]})};#i=({type:i,data:a},r=[],o)=>this.#e?new Promise((e,t)=>{const s=E();this.#e&&this.#e.postMessage({id:s,type:i,data:a},r),this.#t[s]=e,this.#s[s]=t,o?.addEventListener("abort",()=>{t(new DOMException(`Message # ${s} was aborted`,"AbortError"))},{once:!0})}):Promise.reject(p);on(e,t){"log"===e?this.#r.push(t):"progress"===e&&this.#a.push(t)}off(e,t){"log"===e?this.#r=this.#r.filter(e=>e!==t):"progress"===e&&(this.#a=this.#a.filter(e=>e!==t))}load=({classWorkerURL:e,...t}={},{signal:s}={})=>(this.#e||(this.#e=e?new Worker(new URL(e,"file:///Users/focus/Projects/ffmpeg.wasm/packages/ffmpeg/dist/esm/classes.js"),{type:"module"}):new Worker(new URL(i.p+i.u(814),i.b),{type:void 0}),this.#o()),this.#i({type:r.LOAD,data:t},void 0,s));exec=(e,t=-1,{signal:s}={})=>this.#i({type:r.EXEC,data:{args:e,timeout:t}},void 0,s);ffprobe=(e,t=-1,{signal:s}={})=>this.#i({type:r.FFPROBE,data:{args:e,timeout:t}},void 0,s);terminate=()=>{for(const e of Object.keys(this.#s))this.#s[e](d),delete this.#s[e],delete this.#t[e];this.#e&&(this.#e.terminate(),this.#e=null,this.loaded=!1)};writeFile=(e,t,{signal:s}={})=>{const i=[];return t instanceof Uint8Array&&i.push(t.buffer),this.#i({type:r.WRITE_FILE,data:{path:e,data:t}},i,s)};mount=(e,t,s)=>this.#i({type:r.MOUNT,data:{fsType:e,options:t,mountPoint:s}},[]);unmount=e=>this.#i({type:r.UNMOUNT,data:{mountPoint:e}},[]);readFile=(e,t="binary",{signal:s}={})=>this.#i({type:r.READ_FILE,data:{path:e,encoding:t}},void 0,s);deleteFile=(e,{signal:t}={})=>this.#i({type:r.DELETE_FILE,data:{path:e}},void 0,t);rename=(e,t,{signal:s}={})=>this.#i({type:r.RENAME,data:{oldPath:e,newPath:t}},void 0,s);createDir=(e,{signal:t}={})=>this.#i({type:r.CREATE_DIR,data:{path:e}},void 0,t);listDir=(e,{signal:t}={})=>this.#i({type:r.LIST_DIR,data:{path:e}},void 0,t);deleteDir=(e,{signal:t}={})=>this.#i({type:r.DELETE_DIR,data:{path:e}},void 0,t)}return(n=o=o||{}).MEMFS="MEMFS",n.NODEFS="NODEFS",n.NODERAWFS="NODERAWFS",n.IDBFS="IDBFS",n.WORKERFS="WORKERFS",n.PROXYFS="PROXYFS",t})());
|
||||
2
static/js/html-to-image.js
Normal file
2
static/js/html-to-image.js
Normal file
File diff suppressed because one or more lines are too long
20
static/js/html2canvas.min.js
vendored
Normal file
20
static/js/html2canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user