Files
workbuddy_skills/oil-motion/assets/chroma-video-renderer.ts
T
admin 237a09a5b0 修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。

二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。

三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
2026-10-08 22:29:52 +08:00

444 lines
13 KiB
TypeScript

export type ChromaKeyingParameters = {
algorithm: "dominance-v2";
mode: "green" | "magenta";
keyColor: [number, number, number];
similarity: number;
smoothness: number;
dominanceStart: number;
dominanceEnd: number;
spillStart: number;
spillEnd: number;
spill: number;
};
export type ChromaVideoRendererOptions = {
video: HTMLVideoElement;
canvas: HTMLCanvasElement;
frameCount: number;
fps: number;
keying?: ChromaKeyingParameters;
keyColor?: [number, number, number];
similarity?: number;
smoothness?: number;
spill?: number;
maxDpr?: number;
onFrame?: (frame: number, seekMilliseconds: number) => void;
};
export type ChromaVideoRenderer = {
render(frame: number): void;
startLive(): void;
stopLive(): void;
resize(): void;
destroy(): void;
};
const VERTEX_SHADER = `
attribute vec2 aPosition;
varying vec2 vTextureCoordinate;
uniform vec2 uUvScale;
uniform vec2 uUvOffset;
void main() {
vec2 normalized = (aPosition + 1.0) * 0.5;
vTextureCoordinate = normalized * uUvScale + uUvOffset;
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;
const FRAGMENT_SHADER = `
precision mediump float;
varying vec2 vTextureCoordinate;
uniform sampler2D uTexture;
uniform vec3 uKeyColor;
uniform float uSimilarity;
uniform float uSmoothness;
uniform float uKeyMode;
uniform float uDominanceStart;
uniform float uDominanceEnd;
uniform float uSpillStart;
uniform float uSpillEnd;
uniform float uSpill;
vec2 chroma(vec3 color) {
float luminance = dot(color, vec3(0.299, 0.587, 0.114));
return vec2(color.b - luminance, color.r - luminance);
}
void main() {
vec4 sampleColor = texture2D(uTexture, vTextureCoordinate);
float distanceFromKey = distance(chroma(sampleColor.rgb), chroma(uKeyColor));
float distanceAlpha = smoothstep(
uSimilarity,
uSimilarity + max(0.0001, uSmoothness),
distanceFromKey
);
vec3 color = sampleColor.rgb;
float greenDominance = color.g - max(color.r, color.b);
float magentaDominance = min(color.r, color.b) - color.g;
float keyDominance = mix(greenDominance, magentaDominance, uKeyMode);
float dominanceMask = smoothstep(
uDominanceStart,
uDominanceEnd,
keyDominance
);
float alpha = min(distanceAlpha, 1.0 - dominanceMask);
float dominanceSpill = smoothstep(uSpillStart, uSpillEnd, keyDominance);
float spillMask = clamp(
max(dominanceSpill, (1.0 - alpha) * uSpill),
0.0,
1.0
);
if (uKeyMode < 0.5) {
color.g = mix(color.g, max(color.r, color.b), spillMask);
} else {
color.r = mix(color.r, color.g, spillMask);
color.b = mix(color.b, color.g, spillMask);
}
gl_FragColor = vec4(color * alpha, alpha);
}
`;
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
function compileShader(
gl: WebGLRenderingContext,
type: number,
source: string,
) {
const shader = gl.createShader(type);
if (!shader) throw new Error("无法创建 WebGL Shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader) ?? "未知 Shader 编译错误";
gl.deleteShader(shader);
throw new Error(message);
}
return shader;
}
function createProgram(gl: WebGLRenderingContext) {
const program = gl.createProgram();
if (!program) throw new Error("无法创建 WebGL Program");
const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(program) ?? "未知 WebGL 链接错误";
gl.deleteProgram(program);
throw new Error(message);
}
return program;
}
function requiredLocation(
value: WebGLUniformLocation | null,
name: string,
): WebGLUniformLocation {
if (value === null) throw new Error(`缺少 WebGL 变量:${name}`);
return value;
}
export function createChromaVideoRenderer(
options: ChromaVideoRendererOptions,
): ChromaVideoRenderer {
const { video, canvas } = options;
const frameCount = Math.max(1, Math.floor(options.frameCount));
const fps = Math.max(1, options.fps);
const legacyKeyColor = options.keyColor ?? [0, 255, 0];
const keying: ChromaKeyingParameters = options.keying ?? {
algorithm: "dominance-v2",
mode:
legacyKeyColor[0] >= legacyKeyColor[1] + 40 &&
legacyKeyColor[2] >= legacyKeyColor[1] + 40
? "magenta"
: "green",
keyColor: legacyKeyColor,
similarity: options.similarity ?? 0.12,
smoothness: options.smoothness ?? 0.06,
dominanceStart: 0,
dominanceEnd: 0.12,
spillStart: -0.005,
spillEnd: 0.06,
spill: options.spill ?? 1,
};
if (keying.algorithm !== "dominance-v2") {
throw new Error(`不支持的色键算法:${keying.algorithm}`);
}
const maxDpr = options.maxDpr ?? 2;
const gl = canvas.getContext("webgl", {
alpha: true,
antialias: false,
premultipliedAlpha: true,
preserveDrawingBuffer: false,
});
if (!gl) throw new Error("当前浏览器无法创建 WebGL 绿幕渲染器");
const program = createProgram(gl);
const positionLocation = gl.getAttribLocation(program, "aPosition");
if (positionLocation < 0) throw new Error("缺少 WebGL 顶点位置变量");
const uvScaleLocation = requiredLocation(
gl.getUniformLocation(program, "uUvScale"),
"uUvScale",
);
const uvOffsetLocation = requiredLocation(
gl.getUniformLocation(program, "uUvOffset"),
"uUvOffset",
);
const keyColorLocation = requiredLocation(
gl.getUniformLocation(program, "uKeyColor"),
"uKeyColor",
);
const similarityLocation = requiredLocation(
gl.getUniformLocation(program, "uSimilarity"),
"uSimilarity",
);
const smoothnessLocation = requiredLocation(
gl.getUniformLocation(program, "uSmoothness"),
"uSmoothness",
);
const keyModeLocation = requiredLocation(
gl.getUniformLocation(program, "uKeyMode"),
"uKeyMode",
);
const dominanceStartLocation = requiredLocation(
gl.getUniformLocation(program, "uDominanceStart"),
"uDominanceStart",
);
const dominanceEndLocation = requiredLocation(
gl.getUniformLocation(program, "uDominanceEnd"),
"uDominanceEnd",
);
const spillStartLocation = requiredLocation(
gl.getUniformLocation(program, "uSpillStart"),
"uSpillStart",
);
const spillEndLocation = requiredLocation(
gl.getUniformLocation(program, "uSpillEnd"),
"uSpillEnd",
);
const spillLocation = requiredLocation(
gl.getUniformLocation(program, "uSpill"),
"uSpill",
);
const buffer = gl.createBuffer();
const texture = gl.createTexture();
if (!buffer || !texture) throw new Error("无法创建 WebGL 缓冲区");
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
let destroyed = false;
let seeking = false;
let pendingFrame: number | null = 0;
let requestedFrame = 0;
let seekStartedAt = 0;
let live = false;
let liveHandle = 0;
const resize = () => {
if (destroyed) return;
const rectangle = canvas.getBoundingClientRect();
const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);
const width = Math.max(1, Math.round(rectangle.width * dpr));
const height = Math.max(1, Math.round(rectangle.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
gl.viewport(0, 0, width, height);
};
const updateCoverTransform = () => {
const sourceAspect = video.videoWidth / Math.max(1, video.videoHeight);
const canvasAspect = canvas.width / Math.max(1, canvas.height);
let scaleX = 1;
let scaleY = 1;
if (sourceAspect > canvasAspect) scaleX = canvasAspect / sourceAspect;
else scaleY = sourceAspect / canvasAspect;
gl.uniform2f(uvScaleLocation, scaleX, scaleY);
gl.uniform2f(uvOffsetLocation, (1 - scaleX) / 2, (1 - scaleY) / 2);
};
let contextLost = false;
const draw = () => {
if (destroyed || contextLost || video.readyState < video.HAVE_CURRENT_DATA) return;
resize();
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
video,
);
updateCoverTransform();
gl.uniform3f(
keyColorLocation,
keying.keyColor[0] / 255,
keying.keyColor[1] / 255,
keying.keyColor[2] / 255,
);
gl.uniform1f(similarityLocation, keying.similarity);
gl.uniform1f(smoothnessLocation, keying.smoothness);
gl.uniform1f(keyModeLocation, keying.mode === "magenta" ? 1 : 0);
gl.uniform1f(dominanceStartLocation, keying.dominanceStart);
gl.uniform1f(dominanceEndLocation, keying.dominanceEnd);
gl.uniform1f(spillStartLocation, keying.spillStart);
gl.uniform1f(spillEndLocation, keying.spillEnd);
gl.uniform1f(spillLocation, keying.spill);
gl.drawArrays(gl.TRIANGLES, 0, 6);
};
const cancelLiveFrame = () => {
if (!liveHandle) return;
if (video.cancelVideoFrameCallback) {
video.cancelVideoFrameCallback(liveHandle);
} else {
cancelAnimationFrame(liveHandle);
}
liveHandle = 0;
};
const scheduleLiveFrame = () => {
cancelLiveFrame();
if (!live || destroyed) return;
const callback = () => {
liveHandle = 0;
if (!live || destroyed) return;
draw();
scheduleLiveFrame();
};
if (video.requestVideoFrameCallback) {
liveHandle = video.requestVideoFrameCallback(callback);
} else {
liveHandle = requestAnimationFrame(callback);
}
};
const flush = () => {
if (
destroyed ||
seeking ||
pendingFrame === null ||
!Number.isFinite(video.duration) ||
video.duration <= 0
) return;
const nextFrame = pendingFrame;
pendingFrame = null;
const nextTime = Math.min(
nextFrame / fps,
Math.max(0, video.duration - 1 / fps),
);
if (Math.abs(video.currentTime - nextTime) <= 1 / (fps * 2)) {
draw();
options.onFrame?.(nextFrame, 0);
return;
}
requestedFrame = nextFrame;
seeking = true;
seekStartedAt = performance.now();
video.currentTime = nextTime;
};
const handleSeeked = () => {
seeking = false;
if (pendingFrame !== null) {
if (pendingFrame !== requestedFrame) {
flush();
return;
}
pendingFrame = null;
}
draw();
options.onFrame?.(requestedFrame, performance.now() - seekStartedAt);
flush();
};
const handleLoaded = () => {
video.pause();
draw();
flush();
};
const handleVideoError = () => {
seeking = false;
pendingFrame = null;
};
const handleContextLost = (event: Event) => {
event.preventDefault();
contextLost = true;
cancelLiveFrame();
};
const handleContextRestored = () => {
contextLost = false;
draw();
};
const resizeObserver = new ResizeObserver(() => draw());
resizeObserver.observe(canvas);
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.addEventListener("loadeddata", handleLoaded);
video.addEventListener("seeked", handleSeeked);
video.addEventListener("error", handleVideoError);
canvas.addEventListener("webglcontextlost", handleContextLost);
canvas.addEventListener("webglcontextrestored", handleContextRestored);
if (video.readyState >= video.HAVE_CURRENT_DATA) handleLoaded();
return {
render(frame: number) {
pendingFrame = Math.round(clamp(frame, 0, frameCount - 1));
flush();
},
startLive() {
live = true;
draw();
scheduleLiveFrame();
},
stopLive() {
live = false;
cancelLiveFrame();
},
resize,
destroy() {
destroyed = true;
live = false;
cancelLiveFrame();
resizeObserver.disconnect();
video.removeEventListener("loadeddata", handleLoaded);
video.removeEventListener("seeked", handleSeeked);
video.removeEventListener("error", handleVideoError);
canvas.removeEventListener("webglcontextlost", handleContextLost);
canvas.removeEventListener("webglcontextrestored", handleContextRestored);
gl.deleteTexture(texture);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
},
};
}