修: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:
admin committed 2026-10-08 22:29:52 +08:00
1 parent e03465c398
commit 237a09a5b0
161 files changed
+19429 -2

No files matched your search

+443
View File
@@ -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.
+512
View File
@@ -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
}
+46
View File
@@ -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

+78
View File
@@ -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();
},
};
}