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:
305
20251012/sponsor-list/background.js
Normal file
305
20251012/sponsor-list/background.js
Normal 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();
|
||||||
|
})();
|
||||||
97
20251012/sponsor-list/index.html
Normal file
97
20251012/sponsor-list/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user