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.
328 lines
11 KiB
JavaScript
328 lines
11 KiB
JavaScript
(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: 0,
|
||
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();
|
||
})();
|