Files
workbuddy_skills/oil-motion/assets/interactive-motion.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

513 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
},
};
}