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.
This commit is contained in:
xiaomai
2025-10-12 11:23:34 +08:00
parent bcbae992a3
commit 02065d9c63
2 changed files with 402 additions and 0 deletions

View File

@@ -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();
})();

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Canvas 实时抠像(可控面板)</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
@import "tailwindcss";
@theme {
--color-default: #4f46e5;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
}
canvas#mainCanvas {
image-rendering: optimizeQuality;
}
</style>
</head>
<body class="min-h-screen flex items-center content-center">
<div class="stage" id="stage" class="relative w-full overflow-hidden">
<canvas id="mainCanvas" class="block w-full h-auto"></canvas>
<!-- 来源资源:替换为你自己的文件,若跨域请加 crossorigin="anonymous" 并确保服务器允许 CORS -->
<video
id="video"
class="hidden"
playsinline
muted
loop
autoplay
src="赵子龙元帅-竖屏.mp4"
></video>
<!-- Controls 面板(可关闭) -->
<div
class="absolute right-2 top-2 w-[320px] bg-white p-2 rounded-md hidden"
id="controlsPanel"
role="region"
aria-label="视频抠像控制面板"
>
<h4>
控制面板
<button class="close-btn" id="closePanel" title="关闭面板"></button>
</h4>
<div class="grid grid-cols-2 grid-rows-7">
<label>黑色阈值: <span id="thVal">30</span></label>
<input type="range" id="threshold" min="0" max="120" value="30" />
<label>羽化softness: <span id="sfVal">30</span></label>
<input type="range" id="softness" min="0" max="120" value="30" />
<label>左侧偏移 X (px): <span id="marginVal">40</span></label>
<input type="range" id="leftMargin" min="0" max="800" value="40" />
<label>顶部偏移 Y (px): <span id="topVal">0</span></label>
<input type="range" id="topOffset" min="-400" max="400" value="0" />
<label>缩放(%: <span id="scaleVal">90</span></label>
<input type="range" id="scale" min="20" max="200" value="90" />
<label>旋转deg: <span id="rotVal">0</span></label>
<input type="range" id="rotation" min="-45" max="45" value="0" />
<label>不透明度: <span id="opaVal">100</span>%</label>
<input type="range" id="opacity" min="0" max="100" value="100" />
</div>
<div style="display: flex; gap: 6px; margin-top: 8px">
<button
id="resetBtn"
class="flex-1 bg-danger rounded-md cursor-pointer"
>
重置 (R)
</button>
<button
id="applyHint"
class="flex-1 bg-warning rounded-md cursor-pointer"
>
帮助
</button>
</div>
</div>
<!-- 当面板关闭时显示的打开按钮 -->
<button id="openBtn" class="open-controls" style="display: none">
打开控制面板
</button>
</div>
<script src="background.js"></script>
</body>
</html>