Files
dinner.tootaio.com/20251012/main/background.js
xiaomai eb4ca98763 feat(presentation): add multi-page event presentation with navigation
This commit introduces a complete multi-page presentation system for the 20251012 event.

Key features include:
- **Page Structure:** Establishes the page flow with an entry point, a main cover scene, a committee list, and
individual speech video pages.
- **Real-time Chroma Key:** Implements a canvas-based green screen effect to composite a video onto a background image.
A control panel allows for real-time adjustment of keying parameters, position, and scale.
- **Unified Navigation:** A new `nav.js` script enables seamless navigation between pages using keyboard (arrow keys,
Enter), touch swipes, and on-screen buttons. The navigation logic follows a predefined sequence (entry -> main ->
committee -> speeches).
- **Dynamic Content:** The committee list page dynamically generates its content from a JavaScript data source.
2025-10-13 08:00:15 +08:00

328 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
// --- DOM & 元素 ---
const video = document.getElementById("video");
const mainCanvas = document.getElementById("mainCanvas");
const ctxMain = mainCanvas.getContext("2d", { alpha: true });
// temp canvas 用于视频帧处理
const tempCanvas = document.createElement("canvas");
const ctxTemp = tempCanvas.getContext("2d");
// 控件
const controlsPanel = document.getElementById("controlsPanel");
const closePanel = document.getElementById("closePanel");
const thresholdInput = document.getElementById("threshold");
const softnessInput = document.getElementById("softness");
const leftMarginInput = document.getElementById("leftMargin");
const topOffsetInput = document.getElementById("topOffset");
const scaleInput = document.getElementById("scale");
const rotationInput = document.getElementById("rotation");
const opacityInput = document.getElementById("opacity");
// labels
const thVal = document.getElementById("thVal");
const sfVal = document.getElementById("sfVal");
const marginVal = document.getElementById("marginVal");
const topVal = document.getElementById("topVal");
const scaleVal = document.getElementById("scaleVal");
const rotVal = document.getElementById("rotVal");
const opaVal = document.getElementById("opaVal");
// reset / help
const resetBtn = document.getElementById("resetBtn");
const applyHint = document.getElementById("applyHint");
// 默认参数
const defaults = {
threshold: 30,
softness: 10,
// leftMargin: 0,
// topOffset: -200,
// scalePercent: 125
leftMargin: 600,
topOffset: -200,
scalePercent: 125,
rotationDeg: 0,
opacityPercent: 100,
};
// 当前参数init to defaults
let THRESHOLD = defaults.threshold;
let SOFTNESS = defaults.softness;
let LEFT_MARGIN = defaults.leftMargin;
let TOP_OFFSET = defaults.topOffset;
let SCALE = defaults.scalePercent / 100;
let ROTATION = defaults.rotationDeg;
let OPACITY = defaults.opacityPercent / 100;
// 工具函数:同步控制值到 UI
function syncUI() {
thresholdInput.value = THRESHOLD;
softnessInput.value = SOFTNESS;
leftMarginInput.value = LEFT_MARGIN;
topOffsetInput.value = TOP_OFFSET;
scaleInput.value = Math.round(SCALE * 100);
rotationInput.value = ROTATION;
opacityInput.value = Math.round(OPACITY * 100);
thVal.textContent = THRESHOLD;
sfVal.textContent = SOFTNESS;
marginVal.textContent = LEFT_MARGIN;
topVal.textContent = TOP_OFFSET;
scaleVal.textContent = Math.round(SCALE * 100);
rotVal.textContent = ROTATION;
opaVal.textContent = Math.round(OPACITY * 100);
}
// 初始化 UI
syncUI();
// 事件监听:控件变化
thresholdInput.addEventListener("input", (e) => {
THRESHOLD = +e.target.value;
thVal.textContent = THRESHOLD;
});
softnessInput.addEventListener("input", (e) => {
SOFTNESS = +e.target.value;
sfVal.textContent = SOFTNESS;
});
leftMarginInput.addEventListener("input", (e) => {
LEFT_MARGIN = +e.target.value;
marginVal.textContent = LEFT_MARGIN;
});
topOffsetInput.addEventListener("input", (e) => {
TOP_OFFSET = +e.target.value;
topVal.textContent = TOP_OFFSET;
});
scaleInput.addEventListener("input", (e) => {
SCALE = +e.target.value / 100;
scaleVal.textContent = Math.round(SCALE * 100);
});
rotationInput.addEventListener("input", (e) => {
ROTATION = +e.target.value;
rotVal.textContent = ROTATION;
});
opacityInput.addEventListener("input", (e) => {
OPACITY = +e.target.value / 100;
opaVal.textContent = Math.round(OPACITY * 100);
});
// 重置
function resetParams() {
THRESHOLD = defaults.threshold;
SOFTNESS = defaults.softness;
LEFT_MARGIN = defaults.leftMargin;
TOP_OFFSET = defaults.topOffset;
SCALE = defaults.scalePercent / 100;
ROTATION = defaults.rotationDeg;
OPACITY = defaults.opacityPercent / 100;
syncUI();
}
resetBtn.addEventListener("click", resetParams);
document.addEventListener("keydown", (e) => {
if (e.key.toLowerCase() === "r") {
resetParams();
}
if (e.key.toLowerCase() === "c") {
toggleControls();
}
});
applyHint.addEventListener("click", () => {
alert(
"提示:\n• 若画面卡顿,请降低“缩放”或在代码中把 tempCanvas 缩小(注释内说明)。\n• 跨域资源需要 CORS。"
);
});
// 控件开关(可关闭/打开)
function hideControls() {
controlsPanel.style.display = "none";
}
function showControls() {
controlsPanel.style.display = "block";
}
function toggleControls() {
if (
controlsPanel.style.display === "none" ||
getComputedStyle(controlsPanel).display === "none"
)
showControls();
else hideControls();
}
closePanel.addEventListener("click", hideControls);
// 载入背景图(使用背景图做最终合成;如果你希望使用 CSS 背景而不是图片文件,可改这里)
const bgImg = new Image();
bgImg.src = "../assets/background.png";
bgImg.onload = () => {
// 等背景加载后设置主 canvas 大小
const w = bgImg.naturalWidth || 1080;
const h = bgImg.naturalHeight || Math.floor((w * 9) / 16);
mainCanvas.width = w;
mainCanvas.height = h;
mainCanvas.style.width = "100%";
mainCanvas.style.height = "auto";
// tempCanvas 尺寸以视频为准(或缩放以提高性能)
const vW = video.videoWidth || 720;
const vH = video.videoHeight || 1280;
// 性能提示:如果你的原始视频非常大,建议把 tempCanvas 设为更小的尺寸(比如 /2 或 /3
tempCanvas.width = vW;
tempCanvas.height = vH;
// 如果 video 元数据还没准备好,等待 loadedmetadata 事件
if (video.readyState < 1) {
video.addEventListener(
"loadedmetadata",
() => {
requestAnimationFrame(loop);
},
{ once: true }
);
} else {
requestAnimationFrame(loop);
}
};
bgImg.onerror = () => {
console.error("背景图片加载失败,请检查 background.png 路径与 CORS 设置。");
// 给 canvas 一个安全的大小以便继续
mainCanvas.width = 960;
mainCanvas.height = 540;
tempCanvas.width = 720;
tempCanvas.height = 1280;
requestAnimationFrame(loop);
};
// 主渲染循环
let lastTime = performance.now();
function loop(now) {
// 绘制背景
ctxMain.clearRect(0, 0, mainCanvas.width, mainCanvas.height);
if (bgImg.complete && bgImg.naturalWidth) {
ctxMain.drawImage(bgImg, 0, 0, mainCanvas.width, mainCanvas.height);
} else {
// fallback background
ctxMain.fillStyle = "#222";
ctxMain.fillRect(0, 0, mainCanvas.width, mainCanvas.height);
}
// 只有当视频可用时继续
if (!video.paused && !video.ended && video.readyState >= 2) {
// 将视频帧绘制到 tempCanvas此处使用视频自身分辨率
ctxTemp.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
ctxTemp.drawImage(video, 0, 0, tempCanvas.width, tempCanvas.height);
// 处理像素(抠像)
let processed = false;
try {
const img = ctxTemp.getImageData(
0,
0,
tempCanvas.width,
tempCanvas.height
);
const data = img.data;
const len = data.length;
const threshold = THRESHOLD;
const softness = Math.max(1, SOFTNESS);
// // 基于平均亮度的简单抠像:黑色 -> 透明
// for (let i = 0; i < len; i += 4) {
// const r = data[i],
// g = data[i + 1],
// b = data[i + 2];
// const lum = (r + g + b) / 3;
// if (lum < threshold) {
// data[i + 3] = 0;
// } else if (lum < threshold + softness) {
// const t = (lum - threshold) / softness;
// data[i + 3] = Math.round(255 * t);
// } // else keep alpha
// }
// 基于绿色背景的抠像Green Screen / Chroma Key
for (let i = 0; i < len; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// “绿色程度”与“非绿色程度”的差距
const greenDiff = g - Math.max(r, b);
// 以 threshold / softness 控制去除强度和平滑边缘
if (greenDiff > threshold) {
data[i + 3] = 0; // 透明
} else if (greenDiff > threshold - softness) {
const t = (threshold - greenDiff) / softness;
data[i + 3] = Math.round(255 * t);
} // 其他保持原样
}
ctxTemp.putImageData(img, 0, 0);
processed = true;
} catch (err) {
// 可能因为跨域导致 SecurityError — 降级处理:不处理像素,直接绘制视频帧(但看不到透明)
if (err && err.name === "SecurityError") {
// 记录一次性警告到控制台
if (!window._crossOriginWarned) {
console.warn(
'无法读取像素(可能为跨域资源),请确认 video/background 的 CORS 设置use crossorigin="anonymous" + Access-Control-Allow-Origin。'
);
window._crossOriginWarned = true;
}
processed = false;
} else {
console.error("处理像素时发生错误:", err);
}
}
// 把处理后或原始的tempCanvas 绘制到主 canvas
// 计算目标大小(以主 canvas 的高度比例为基准)
const desiredHeightRatio = 0.9; // 视频占主画布高度的比例(可改)
const destH = Math.floor(mainCanvas.height * desiredHeightRatio);
const baseScale = destH / tempCanvas.height;
const destW = Math.floor(tempCanvas.width * baseScale);
const destX = LEFT_MARGIN;
const destY = Math.floor((mainCanvas.height - destH) / 2) + TOP_OFFSET;
// 应用用户控制的缩放、旋转、不透明度
const scaledW = destW * SCALE;
const scaledH = destH * SCALE;
const centerX = destX + scaledW / 2;
const centerY = destY + scaledH / 2;
const rotRad = (ROTATION * Math.PI) / 180;
ctxMain.save();
ctxMain.globalAlpha = OPACITY;
// translate -> rotate -> draw centered
ctxMain.translate(centerX, centerY);
ctxMain.rotate(rotRad);
// drawImage 参数:绘制 tempCanvas 的内容到以中心为原点的位置
ctxMain.drawImage(
tempCanvas,
-scaledW / 2,
-scaledH / 2,
scaledW,
scaledH
);
ctxMain.restore();
// 如果你想在无法处理像素(跨域)时仍然保持 alpha必须确保视频资源启用 CORS 和服务器返回允许头
// 可在这里根据 processed 变量显示调试信息(未开启)
}
// 下一帧
requestAnimationFrame(loop);
}
// 自动播放策略:尝试播放视频(静音大多数浏览器允许)
video.play().catch(() => {
/* 静音应该可以自动播放,否则需用户交互触发 */
});
// 初始 UI 状态
syncUI();
})();