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.
This commit is contained in:
xiaomai
2025-10-13 08:00:15 +08:00
parent ea4ccec42d
commit eb4ca98763
12 changed files with 1166 additions and 50 deletions

View File

@@ -0,0 +1,327 @@
(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();
})();