From 02065d9c63a73a31ac716f5983d7433409d1d48f Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 12 Oct 2025 11:23:34 +0800 Subject: [PATCH] feat(demo): add real-time canvas video keying demo This commit introduces a new interactive demo that performs real-time video keying using HTML5 Canvas. The implementation processes video frames pixel by pixel to make dark areas transparent, simulating a chroma key effect. The keyed video is then composited over a background image. Features: - An interactive control panel to adjust keying (threshold, softness) and transformation (position, scale, rotation, opacity) parameters in real-time. - Keyboard shortcuts for resetting parameters ('R') and toggling the control panel ('C'). - Graceful handling of potential cross-origin errors when processing video frames. --- 20251012/sponsor-list/background.js | 305 ++++++++++++++++++++++++++++ 20251012/sponsor-list/index.html | 97 +++++++++ 2 files changed, 402 insertions(+) create mode 100644 20251012/sponsor-list/background.js create mode 100644 20251012/sponsor-list/index.html diff --git a/20251012/sponsor-list/background.js b/20251012/sponsor-list/background.js new file mode 100644 index 0000000..a7ffd48 --- /dev/null +++ b/20251012/sponsor-list/background.js @@ -0,0 +1,305 @@ +(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: 8, + softness: 10, + 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 = "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 + } + 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(); +})(); diff --git a/20251012/sponsor-list/index.html b/20251012/sponsor-list/index.html new file mode 100644 index 0000000..8ad6957 --- /dev/null +++ b/20251012/sponsor-list/index.html @@ -0,0 +1,97 @@ + + + + + + Canvas 实时抠像(可控面板) + + + + +
+ + + + + + + + + + +
+ + + +