修: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` 挪回原处即可。
This commit is contained in:
1 parent
e03465c398
commit
237a09a5b0
161 files changed
+19429
-2
No files matched your search
@@ -0,0 +1,443 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,512 @@
|
||||
export type FrameAnimatorOptions = {
|
||||
frameCount: number;
|
||||
initialFrame?: number;
|
||||
circular?: boolean;
|
||||
smoothTime?: number;
|
||||
maxSpeed?: number;
|
||||
reducedMotion?: boolean;
|
||||
render: (frame: number) => void;
|
||||
};
|
||||
|
||||
export type FrameAnimator = {
|
||||
setTarget(frame: number): void;
|
||||
setDirection(x: number, y: number, startAngle?: number): void;
|
||||
setProgress(progress: number): void;
|
||||
getCurrentFrame(): number;
|
||||
destroy(): void;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
const wrap = (value: number, length: number) =>
|
||||
((value % length) + length) % length;
|
||||
|
||||
const shortestCircularDelta = (
|
||||
from: number,
|
||||
to: number,
|
||||
frameCount: number,
|
||||
) => {
|
||||
let delta = wrap(to, frameCount) - wrap(from, frameCount);
|
||||
if (delta > frameCount / 2) delta -= frameCount;
|
||||
if (delta < -frameCount / 2) delta += frameCount;
|
||||
return delta;
|
||||
};
|
||||
|
||||
const smoothDamp = (
|
||||
current: number,
|
||||
target: number,
|
||||
velocity: number,
|
||||
smoothTime: number,
|
||||
maxSpeed: number,
|
||||
deltaTime: number,
|
||||
): [number, number] => {
|
||||
const safeTime = Math.max(0.0001, smoothTime);
|
||||
const omega = 2 / safeTime;
|
||||
const x = omega * deltaTime;
|
||||
const decay = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
|
||||
const originalTarget = target;
|
||||
const maxChange = maxSpeed * safeTime;
|
||||
const change = clamp(current - target, -maxChange, maxChange);
|
||||
const limitedTarget = current - change;
|
||||
const temp = (velocity + omega * change) * deltaTime;
|
||||
let nextVelocity = (velocity - omega * temp) * decay;
|
||||
let nextPosition = limitedTarget + (change + temp) * decay;
|
||||
|
||||
if (
|
||||
(originalTarget - current > 0) ===
|
||||
(nextPosition > originalTarget)
|
||||
) {
|
||||
nextPosition = originalTarget;
|
||||
nextVelocity = 0;
|
||||
}
|
||||
return [nextPosition, nextVelocity];
|
||||
};
|
||||
|
||||
export function createFrameAnimator(
|
||||
options: FrameAnimatorOptions,
|
||||
): FrameAnimator {
|
||||
const frameCount = Math.max(1, Math.floor(options.frameCount));
|
||||
const circular = options.circular ?? false;
|
||||
const smoothTime = options.smoothTime ?? 0.11;
|
||||
const maxSpeed = options.maxSpeed ?? frameCount * 2;
|
||||
const reducedMotion = options.reducedMotion ?? false;
|
||||
let position = clamp(options.initialFrame ?? 0, 0, frameCount - 1);
|
||||
let target = position;
|
||||
let velocity = 0;
|
||||
let lastFrame = -1;
|
||||
let lastTime = 0;
|
||||
let raf = 0;
|
||||
let destroyed = false;
|
||||
|
||||
const normalizeFrame = (frame: number) =>
|
||||
circular
|
||||
? wrap(frame, frameCount)
|
||||
: clamp(frame, 0, frameCount - 1);
|
||||
|
||||
const render = () => {
|
||||
const frame = Math.round(normalizeFrame(position));
|
||||
if (frame !== lastFrame) {
|
||||
options.render(frame);
|
||||
lastFrame = frame;
|
||||
}
|
||||
};
|
||||
|
||||
const loop = (now: number) => {
|
||||
raf = 0;
|
||||
if (destroyed) return;
|
||||
const deltaTime = lastTime
|
||||
? Math.min((now - lastTime) / 1000, 1 / 30)
|
||||
: 1 / 60;
|
||||
lastTime = now;
|
||||
|
||||
if (reducedMotion) {
|
||||
position = target;
|
||||
velocity = 0;
|
||||
} else {
|
||||
[position, velocity] = smoothDamp(
|
||||
position,
|
||||
target,
|
||||
velocity,
|
||||
smoothTime,
|
||||
maxSpeed,
|
||||
deltaTime,
|
||||
);
|
||||
}
|
||||
render();
|
||||
|
||||
if (Math.abs(target - position) > 0.002 || Math.abs(velocity) > 0.002) {
|
||||
raf = requestAnimationFrame(loop);
|
||||
} else if (circular) {
|
||||
position = wrap(position, frameCount);
|
||||
target = position;
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (!raf && !destroyed) raf = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
setTarget(frame: number) {
|
||||
const normalized = normalizeFrame(frame);
|
||||
target = circular
|
||||
? position + shortestCircularDelta(position, normalized, frameCount)
|
||||
: normalized;
|
||||
schedule();
|
||||
},
|
||||
setDirection(x: number, y: number, startAngle = -Math.PI * 0.75) {
|
||||
const angle = Math.atan2(y, x);
|
||||
const turn = Math.PI * 2;
|
||||
const normalized = ((angle - startAngle + turn) % turn) / turn;
|
||||
this.setTarget(normalized * frameCount);
|
||||
},
|
||||
setProgress(progress: number) {
|
||||
this.setTarget(clamp(progress, 0, 1) * (frameCount - 1));
|
||||
},
|
||||
getCurrentFrame() {
|
||||
return normalizeFrame(position);
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createCssSpriteRenderer(
|
||||
element: HTMLElement,
|
||||
columns: number,
|
||||
rows: number,
|
||||
) {
|
||||
const safeColumns = Math.max(1, Math.floor(columns));
|
||||
const safeRows = Math.max(1, Math.floor(rows));
|
||||
element.style.backgroundSize = `${safeColumns * 100}% ${safeRows * 100}%`;
|
||||
|
||||
return (frame: number) => {
|
||||
const column = frame % safeColumns;
|
||||
const row = Math.floor(frame / safeColumns);
|
||||
const x = safeColumns === 1 ? 0 : (column / (safeColumns - 1)) * 100;
|
||||
const y = safeRows === 1 ? 0 : (row / (safeRows - 1)) * 100;
|
||||
element.style.backgroundPosition = `${x}% ${y}%`;
|
||||
};
|
||||
}
|
||||
|
||||
export type SegmentRateCurve =
|
||||
| { type: "constant"; rate: number }
|
||||
| {
|
||||
type: "edge-mid-edge";
|
||||
edgeRate: number;
|
||||
midRate: number;
|
||||
};
|
||||
|
||||
export type TimelineSegment = {
|
||||
id?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
start: number;
|
||||
hold: number;
|
||||
endExclusive: number;
|
||||
curve?: SegmentRateCurve;
|
||||
};
|
||||
|
||||
export type TimelineState = {
|
||||
id: string;
|
||||
hold: number;
|
||||
};
|
||||
|
||||
export type SegmentPlayerOptions = {
|
||||
video: HTMLVideoElement;
|
||||
segments: readonly TimelineSegment[];
|
||||
states?: readonly TimelineState[];
|
||||
frameDuration: number;
|
||||
initialState?: number | string;
|
||||
reducedMotion?: boolean;
|
||||
onStateChange?: (state: number) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export type SegmentPlayerState = {
|
||||
currentState: number;
|
||||
currentStateId: string;
|
||||
targetState: number;
|
||||
targetStateId: string;
|
||||
playing: boolean;
|
||||
currentTime: number;
|
||||
};
|
||||
|
||||
export type SegmentPlayer = {
|
||||
goTo(state: number | string): void;
|
||||
step(direction: -1 | 1): void;
|
||||
cancel(): void;
|
||||
getState(): SegmentPlayerState;
|
||||
destroy(): void;
|
||||
};
|
||||
|
||||
type VideoFrameCapable = HTMLVideoElement & {
|
||||
requestVideoFrameCallback?: (callback: (now: number) => void) => number;
|
||||
cancelVideoFrameCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
const segmentRate = (segment: TimelineSegment, time: number) => {
|
||||
const curve = segment.curve ?? { type: "constant", rate: 1 };
|
||||
if (curve.type === "constant") return Math.max(0.1, curve.rate);
|
||||
const span = Math.max(0.0001, segment.hold - segment.start);
|
||||
const progress = clamp((time - segment.start) / span, 0, 1);
|
||||
const edgeWeight = Math.cos(Math.PI * progress) ** 2;
|
||||
return Math.max(
|
||||
0.1,
|
||||
curve.midRate + (curve.edgeRate - curve.midRate) * edgeWeight,
|
||||
);
|
||||
};
|
||||
|
||||
const validateSegments = (segments: readonly TimelineSegment[]) => {
|
||||
if (!segments.length) throw new Error("segments 不能为空");
|
||||
segments.forEach((segment, index) => {
|
||||
if (!(segment.start <= segment.hold && segment.hold < segment.endExclusive)) {
|
||||
throw new Error(`segment ${index} 必须满足 start <= hold < endExclusive`);
|
||||
}
|
||||
if (index > 0 && segment.start < segments[index - 1].endExclusive) {
|
||||
throw new Error(`segment ${index} 与上一段时间范围重叠`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function createSegmentPlayer(
|
||||
options: SegmentPlayerOptions,
|
||||
): SegmentPlayer {
|
||||
validateSegments(options.segments);
|
||||
const video = options.video as VideoFrameCapable;
|
||||
const maxState = options.segments.length;
|
||||
const frameDuration = Math.max(0.001, options.frameDuration);
|
||||
const reducedMotion = options.reducedMotion ?? false;
|
||||
const states = options.states ?? [
|
||||
{ id: "state-0", hold: options.segments[0].start },
|
||||
...options.segments.map((segment, index) => ({
|
||||
id: segment.to ?? `state-${index + 1}`,
|
||||
hold: segment.hold,
|
||||
})),
|
||||
];
|
||||
if (states.length !== maxState + 1) {
|
||||
throw new Error("states 数量必须等于 segments 数量加一");
|
||||
}
|
||||
const stateIds = states.map((state) => state.id);
|
||||
if (new Set(stateIds).size !== stateIds.length || stateIds.some((id) => !id)) {
|
||||
throw new Error("timeline state id 必须非空且唯一");
|
||||
}
|
||||
if (Math.abs(states[0].hold - options.segments[0].start) > frameDuration / 2) {
|
||||
throw new Error("初始 state hold 必须对应第一段 start");
|
||||
}
|
||||
options.segments.forEach((segment, index) => {
|
||||
if (segment.from && segment.from !== states[index].id) {
|
||||
throw new Error(`segment ${index} 的 from 与 states 顺序不一致`);
|
||||
}
|
||||
if (segment.to && segment.to !== states[index + 1].id) {
|
||||
throw new Error(`segment ${index} 的 to 与 states 顺序不一致`);
|
||||
}
|
||||
if (Math.abs(states[index + 1].hold - segment.hold) > frameDuration / 2) {
|
||||
throw new Error(`segment ${index} 的 hold 与目标 state 不一致`);
|
||||
}
|
||||
});
|
||||
const resolveState = (state: number | string) => {
|
||||
if (typeof state === "string") {
|
||||
const index = stateIds.indexOf(state);
|
||||
if (index < 0) throw new Error(`未知 timeline state id:${state}`);
|
||||
return index;
|
||||
}
|
||||
return clamp(Math.round(state), 0, maxState);
|
||||
};
|
||||
let currentState = resolveState(options.initialState ?? 0);
|
||||
let targetState = currentState;
|
||||
let running = false;
|
||||
let destroyed = false;
|
||||
let runToken = 0;
|
||||
let scheduledHandle = 0;
|
||||
let scheduledWithVideo = false;
|
||||
let lastTick = 0;
|
||||
|
||||
const stateTime = (state: number) => states[state].hold;
|
||||
|
||||
const cancelScheduled = () => {
|
||||
if (!scheduledHandle) return;
|
||||
if (scheduledWithVideo) {
|
||||
video.cancelVideoFrameCallback?.(scheduledHandle);
|
||||
} else {
|
||||
cancelAnimationFrame(scheduledHandle);
|
||||
}
|
||||
scheduledHandle = 0;
|
||||
};
|
||||
|
||||
const schedule = (direction: -1 | 1, callback: (now: number) => void) => {
|
||||
cancelScheduled();
|
||||
if (direction === 1 && video.requestVideoFrameCallback) {
|
||||
scheduledWithVideo = true;
|
||||
scheduledHandle = video.requestVideoFrameCallback(callback);
|
||||
} else {
|
||||
scheduledWithVideo = false;
|
||||
scheduledHandle = requestAnimationFrame(callback);
|
||||
}
|
||||
};
|
||||
|
||||
const segmentForTime = (time: number, direction: -1 | 1) => {
|
||||
if (direction === 1) {
|
||||
return (
|
||||
options.segments.find(
|
||||
(segment) => time < segment.hold + frameDuration / 2,
|
||||
) ?? options.segments[maxState - 1]
|
||||
);
|
||||
}
|
||||
for (let index = maxState - 1; index >= 0; index -= 1) {
|
||||
if (time > options.segments[index].start - frameDuration / 2) {
|
||||
return options.segments[index];
|
||||
}
|
||||
}
|
||||
return options.segments[0];
|
||||
};
|
||||
|
||||
const removeBoundaryGap = (
|
||||
time: number,
|
||||
direction: -1 | 1,
|
||||
targetTime: number,
|
||||
) => {
|
||||
if (direction === 1) {
|
||||
for (let index = 1; index < maxState; index += 1) {
|
||||
const previousHold = options.segments[index - 1].hold;
|
||||
const nextStart = options.segments[index].start;
|
||||
if (
|
||||
targetTime > previousHold + frameDuration / 2 &&
|
||||
time >= previousHold - frameDuration / 2 &&
|
||||
time < nextStart
|
||||
) return nextStart;
|
||||
}
|
||||
} else {
|
||||
for (let index = maxState - 1; index > 0; index -= 1) {
|
||||
const previousHold = options.segments[index - 1].hold;
|
||||
const nextStart = options.segments[index].start;
|
||||
if (
|
||||
targetTime <= previousHold + frameDuration / 2 &&
|
||||
time > previousHold &&
|
||||
time <= nextStart + frameDuration / 2
|
||||
) return previousHold;
|
||||
}
|
||||
}
|
||||
return time;
|
||||
};
|
||||
|
||||
const settle = () => {
|
||||
cancelScheduled();
|
||||
video.pause();
|
||||
video.currentTime = stateTime(targetState);
|
||||
currentState = targetState;
|
||||
running = false;
|
||||
lastTick = 0;
|
||||
options.onStateChange?.(currentState);
|
||||
};
|
||||
|
||||
const startRun = () => {
|
||||
const token = ++runToken;
|
||||
cancelScheduled();
|
||||
video.pause();
|
||||
const targetTime = stateTime(targetState);
|
||||
const initialTime = removeBoundaryGap(
|
||||
video.currentTime,
|
||||
targetTime >= video.currentTime ? 1 : -1,
|
||||
targetTime,
|
||||
);
|
||||
if (initialTime !== video.currentTime) video.currentTime = initialTime;
|
||||
|
||||
if (reducedMotion || Math.abs(targetTime - video.currentTime) <= frameDuration / 2) {
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
|
||||
const direction: -1 | 1 = targetTime > video.currentTime ? 1 : -1;
|
||||
running = true;
|
||||
lastTick = 0;
|
||||
|
||||
const tick = (now: number) => {
|
||||
scheduledHandle = 0;
|
||||
if (destroyed || token !== runToken) return;
|
||||
|
||||
const boundedTime = removeBoundaryGap(
|
||||
video.currentTime,
|
||||
direction,
|
||||
targetTime,
|
||||
);
|
||||
if (boundedTime !== video.currentTime) video.currentTime = boundedTime;
|
||||
const reached =
|
||||
direction === 1
|
||||
? video.currentTime >= targetTime - frameDuration / 2
|
||||
: video.currentTime <= targetTime + frameDuration / 2;
|
||||
if (reached) {
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
|
||||
const segment = segmentForTime(video.currentTime, direction);
|
||||
const rate = segmentRate(segment, video.currentTime);
|
||||
if (direction === 1) {
|
||||
video.playbackRate = rate;
|
||||
} else {
|
||||
video.pause();
|
||||
const delta = lastTick
|
||||
? Math.min((now - lastTick) / 1000, 1 / 30)
|
||||
: 1 / 60;
|
||||
video.currentTime = Math.max(targetTime, video.currentTime - rate * delta);
|
||||
}
|
||||
lastTick = now;
|
||||
schedule(direction, tick);
|
||||
};
|
||||
|
||||
if (direction === 1) {
|
||||
const segment = segmentForTime(video.currentTime, direction);
|
||||
video.playbackRate = segmentRate(segment, video.currentTime);
|
||||
void video
|
||||
.play()
|
||||
.then(() => {
|
||||
if (token !== runToken || destroyed) return;
|
||||
schedule(direction, tick);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (token !== runToken || destroyed) return;
|
||||
running = false;
|
||||
options.onError?.(error);
|
||||
});
|
||||
} else {
|
||||
schedule(direction, tick);
|
||||
}
|
||||
};
|
||||
|
||||
const seekInitialState = () => {
|
||||
if (destroyed) return;
|
||||
video.currentTime = stateTime(currentState);
|
||||
};
|
||||
if (video.readyState >= 1) seekInitialState();
|
||||
else video.addEventListener("loadedmetadata", seekInitialState, { once: true });
|
||||
|
||||
const handleVideoError = (event: Event) => {
|
||||
if (destroyed) return;
|
||||
cancelScheduled();
|
||||
video.pause();
|
||||
running = false;
|
||||
lastTick = 0;
|
||||
options.onError?.(video.error ?? event);
|
||||
};
|
||||
video.addEventListener("error", handleVideoError);
|
||||
|
||||
return {
|
||||
goTo(state: number | string) {
|
||||
if (destroyed) return;
|
||||
targetState = resolveState(state);
|
||||
startRun();
|
||||
},
|
||||
step(direction: -1 | 1) {
|
||||
this.goTo(targetState + direction);
|
||||
},
|
||||
cancel() {
|
||||
runToken += 1;
|
||||
cancelScheduled();
|
||||
video.pause();
|
||||
running = false;
|
||||
lastTick = 0;
|
||||
},
|
||||
getState() {
|
||||
return {
|
||||
currentState,
|
||||
currentStateId: states[currentState].id,
|
||||
targetState,
|
||||
targetStateId: states[targetState].id,
|
||||
playing: running,
|
||||
currentTime: video.currentTime,
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
this.cancel();
|
||||
destroyed = true;
|
||||
video.removeEventListener("loadedmetadata", seekInitialState);
|
||||
video.removeEventListener("error", handleVideoError);
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"type": "sprite-atlas",
|
||||
"parameterSpace": "circular",
|
||||
"frameCount": 240,
|
||||
"columns": 16,
|
||||
"rows": 15,
|
||||
"cellWidth": 240,
|
||||
"cellHeight": 240,
|
||||
"atlasWidth": 3840,
|
||||
"atlasHeight": 3600,
|
||||
"initialFrame": 157,
|
||||
"startAngleRadians": -2.356194490192345,
|
||||
"circular": true,
|
||||
"sourceFps": 24,
|
||||
"outputFps": 48
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
width="1200" height="420" viewBox="0 0 1200 420"
|
||||
role="img" aria-labelledby="title desc">
|
||||
<title id="title">Oil Motion — 把 AI 视频编译成可交互网页动画</title>
|
||||
<desc id="desc">左侧是项目名称与流程,右侧是油画风格漫画插画:戴圆眼镜的小人通过滚动旋钮控制一条手机动作胶片,黄色牧羊犬注视着手机从完整状态逐步变化成爆炸图。</desc>
|
||||
|
||||
<defs>
|
||||
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
<path d="M32 0H0V32" fill="none" stroke="#1A1A1A" stroke-opacity=".055" stroke-width="1"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="420" rx="28" fill="#FCFAF3"/>
|
||||
<rect width="1200" height="420" rx="28" fill="url(#grid)"/>
|
||||
<path d="M502 34V386" stroke="#1A1A1A" stroke-opacity=".12"/>
|
||||
|
||||
<g id="title-block" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif">
|
||||
<text x="56" y="66" font-size="18" font-weight="700" letter-spacing="3.1" fill="#74726C">AI VIDEO → INTERACTIVE MOTION</text>
|
||||
<text x="52" y="151" font-size="72" font-weight="850" letter-spacing="-3.4" fill="#171717">Oil Motion</text>
|
||||
<rect x="54" y="166" width="302" height="15" fill="#F6C85F" opacity=".78"/>
|
||||
<text x="56" y="224" font-size="29" font-weight="760" fill="#171717">把 AI 视频编译成可交互动画</text>
|
||||
<text x="56" y="261" font-size="20" font-weight="520" fill="#77756F">关键帧锁定结果,MiniMax 补全动作。</text>
|
||||
<text x="56" y="291" font-size="20" font-weight="520" fill="#77756F">程序负责切帧、质检、压缩与映射。</text>
|
||||
|
||||
<g id="process" transform="translate(58 329)"
|
||||
font-family="ui-monospace,SFMono-Regular,Menlo,monospace"
|
||||
font-size="17" font-weight="760">
|
||||
<path d="M9 10H365" fill="none" stroke="#171717" stroke-opacity=".25" stroke-width="2"/>
|
||||
<circle cx="9" cy="10" r="8" fill="#171717"/>
|
||||
<circle cx="184" cy="10" r="8" fill="#F6C85F" stroke="#171717" stroke-width="2"/>
|
||||
<circle cx="365" cy="10" r="8" fill="#D9E4EE" stroke="#171717" stroke-width="2"/>
|
||||
<text x="0" y="43" fill="#171717">KEYFRAMES</text>
|
||||
<text x="139" y="43" fill="#171717">MINIMAX H3</text>
|
||||
<text x="315" y="43" fill="#171717">WEB MOTION</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g id="oil-visual-illustration">
|
||||
<image x="514" y="29" width="668" height="389"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
href="data:image/webp;base64,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 truncated
|
||||
</g>
|
||||
|
||||
<circle cx="1160" cy="36" r="6" fill="#F6C85F"/>
|
||||
<path d="M1139 36H1119M1129 26V46" stroke="#171717" stroke-width="2" opacity=".42"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 277 KiB |
@@ -0,0 +1,15 @@
|
||||
Use case: illustration-story
|
||||
Asset type: transparent hero illustration for the Oil Motion GitHub README
|
||||
Input image: use oil-visual/assets/characters/oil-character-turnaround-v1.png as the exact identity reference for both recurring characters.
|
||||
|
||||
Create one polished editorial manga scene that clearly communicates turning keyframes and AI video into interactive web motion. The glasses stick-figure protagonist operates a long horizontal film-strip workbench. On the left is one intact slim smartphone-like product as the approved start keyframe; across the middle are several continuous motion frames; on the right the same product is cleanly separated into an exploded view as the approved end keyframe. A large physical scroll/scrub wheel and a thin progress rail visibly control which film frame is active. The warm-yellow Border Collie sits beside the rail, watching the active frame with a gentle curious expression. Keep the characters secondary to the film strip and transformation evidence.
|
||||
|
||||
Preserve the supplied person's round head, three-strand hair, thin round glasses with clearly visible solid-black pupils, small smile, loose white T-shirt, slender line limbs, and height-to-head ratio. Preserve the supplied dog's narrow white facial blaze, folded ears, gentle dark eyes, compact seated body, fluffy white chest, warm-yellow coat map, and full side-resting tail. Do not make the dog a round chibi puppy.
|
||||
|
||||
Style: professional oil-visual manga/comic ink illustration; clean confident black outlines with varied line weight; restrained circular halftone screentone; 90% black, white and halftone gray; warm yellow only on the dog, scrubber knob and a few tiny star accents; muted blue only on a few product/frame surfaces. Flat 2D editorial illustration, not 3D.
|
||||
|
||||
Composition: wide horizontal 3:2 scene, readable silhouette, three visual regions from start keyframe to motion frames to end keyframe. Keep generous clear padding around the entire scene for compositing.
|
||||
|
||||
Background: genuine transparency with a preserved alpha channel. Every pixel outside the subject must be fully transparent. No green/magenta/grey/white backdrop, checkerboard, gradient, texture, noise, shadow, floor, horizon, reflection or lighting variation.
|
||||
|
||||
No text, letters, numbers, logo, watermark, interface cards, dashboard, extra limbs, duplicated dog, missing pupils, motion blur, glossy rendering, photorealism or cast shadow. Keep every subject fully separated from all image borders.
|
||||
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="1200" height="420" viewBox="0 0 1200 420"
|
||||
role="img" aria-labelledby="title desc">
|
||||
<title id="title">Oil Motion — 把 AI 视频编译成可交互网页动画</title>
|
||||
<desc id="desc">左侧是项目名称与流程,右侧是油画风格漫画插画:戴圆眼镜的小人通过滚动旋钮控制一条手机动作胶片,黄色牧羊犬注视着手机从完整状态逐步变化成爆炸图。</desc>
|
||||
|
||||
<defs>
|
||||
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
<path d="M32 0H0V32" fill="none" stroke="#1A1A1A" stroke-opacity=".055" stroke-width="1"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="420" rx="28" fill="#FCFAF3"/>
|
||||
<rect width="1200" height="420" rx="28" fill="url(#grid)"/>
|
||||
<path d="M502 34V386" stroke="#1A1A1A" stroke-opacity=".12"/>
|
||||
|
||||
<g id="title-block" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif">
|
||||
<text x="56" y="66" font-size="18" font-weight="700" letter-spacing="3.1" fill="#74726C">AI VIDEO → INTERACTIVE MOTION</text>
|
||||
<text x="52" y="151" font-size="72" font-weight="850" letter-spacing="-3.4" fill="#171717">Oil Motion</text>
|
||||
<rect x="54" y="166" width="302" height="15" fill="#F6C85F" opacity=".78"/>
|
||||
<text x="56" y="224" font-size="29" font-weight="760" fill="#171717">把 AI 视频编译成可交互动画</text>
|
||||
<text x="56" y="261" font-size="20" font-weight="520" fill="#77756F">关键帧锁定结果,MiniMax 补全动作。</text>
|
||||
<text x="56" y="291" font-size="20" font-weight="520" fill="#77756F">程序负责切帧、质检、压缩与映射。</text>
|
||||
|
||||
<g id="process" transform="translate(58 329)"
|
||||
font-family="ui-monospace,SFMono-Regular,Menlo,monospace"
|
||||
font-size="17" font-weight="760">
|
||||
<path d="M9 10H365" fill="none" stroke="#171717" stroke-opacity=".25" stroke-width="2"/>
|
||||
<circle cx="9" cy="10" r="8" fill="#171717"/>
|
||||
<circle cx="184" cy="10" r="8" fill="#F6C85F" stroke="#171717" stroke-width="2"/>
|
||||
<circle cx="365" cy="10" r="8" fill="#D9E4EE" stroke="#171717" stroke-width="2"/>
|
||||
<text x="0" y="43" fill="#171717">KEYFRAMES</text>
|
||||
<text x="139" y="43" fill="#171717">MINIMAX H3</text>
|
||||
<text x="315" y="43" fill="#171717">WEB MOTION</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g id="oil-visual-illustration">
|
||||
<image x="514" y="29" width="668" height="389"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
href="./hero-illustration-crop-v1.webp"
|
||||
xlink:href="./hero-illustration-crop-v1.webp"/>
|
||||
</g>
|
||||
|
||||
<circle cx="1160" cy="36" r="6" fill="#F6C85F"/>
|
||||
<path d="M1139 36H1119M1129 26V46" stroke="#171717" stroke-width="2" opacity=".42"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,78 @@
|
||||
export type StepDirection = -1 | 1;
|
||||
|
||||
export type StepGestureOptions = {
|
||||
threshold?: number;
|
||||
idleMilliseconds?: number;
|
||||
onStep: (direction: StepDirection) => void;
|
||||
};
|
||||
|
||||
export type StepGestureAdapter = {
|
||||
feed(delta: number): void;
|
||||
setProgrammaticNavigation(active: boolean): void;
|
||||
reset(): void;
|
||||
destroy(): void;
|
||||
};
|
||||
|
||||
export function createStepGestureAdapter(
|
||||
options: StepGestureOptions,
|
||||
): StepGestureAdapter {
|
||||
const threshold = Math.max(1, options.threshold ?? 40);
|
||||
const idleMilliseconds = Math.max(16, options.idleMilliseconds ?? 140);
|
||||
let accumulated = 0;
|
||||
let lockedDirection: StepDirection | 0 = 0;
|
||||
let programmaticNavigation = false;
|
||||
let releaseTimer = 0;
|
||||
let destroyed = false;
|
||||
|
||||
const clearRelease = () => {
|
||||
if (releaseTimer) window.clearTimeout(releaseTimer);
|
||||
releaseTimer = 0;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
clearRelease();
|
||||
accumulated = 0;
|
||||
lockedDirection = 0;
|
||||
};
|
||||
|
||||
const scheduleRelease = () => {
|
||||
clearRelease();
|
||||
releaseTimer = window.setTimeout(reset, idleMilliseconds);
|
||||
};
|
||||
|
||||
return {
|
||||
feed(delta: number) {
|
||||
if (
|
||||
destroyed ||
|
||||
programmaticNavigation ||
|
||||
!Number.isFinite(delta) ||
|
||||
delta === 0
|
||||
) return;
|
||||
const direction: StepDirection = delta < 0 ? -1 : 1;
|
||||
if (lockedDirection === direction) {
|
||||
scheduleRelease();
|
||||
return;
|
||||
}
|
||||
if (lockedDirection === -direction) {
|
||||
accumulated = 0;
|
||||
lockedDirection = 0;
|
||||
}
|
||||
if (accumulated && Math.sign(accumulated) !== direction) accumulated = 0;
|
||||
accumulated += delta;
|
||||
scheduleRelease();
|
||||
if (Math.abs(accumulated) < threshold) return;
|
||||
accumulated = 0;
|
||||
lockedDirection = direction;
|
||||
options.onStep(direction);
|
||||
},
|
||||
setProgrammaticNavigation(active: boolean) {
|
||||
programmaticNavigation = active;
|
||||
if (active) reset();
|
||||
},
|
||||
reset,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
reset();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user