Files
workbuddy_skills/oil-motion/assets/interactive-motion.ts
T

512 lines
15 KiB
TypeScript
Raw Normal View History

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);
},
};
}