初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
// biome-ignore lint/complexity/noBannedTypes: for debounce function
export function debounce<T extends Function>(cb: T, wait = 20) {
let h = 0;
// biome-ignore lint/suspicious/noExplicitAny: function can be any
const callable = (...args: any) => {
clearTimeout(h);
h = setTimeout(() => cb(...args), wait);
};
return callable;
}
// biome-ignore lint/complexity/noBannedTypes: for debounce function
export function debounceFrame<T extends Function>(cb: T, frameTime = 1) {
let h = 0;
let ft = frameTime;
// biome-ignore lint/suspicious/noExplicitAny: function can be any
const callable = (...args: any) => {
ft = frameTime;
cancelAnimationFrame(h);
const onCB = () => {
if (--ft <= 0) {
cb(...args);
} else {
h = requestAnimationFrame(onCB);
}
};
h = requestAnimationFrame(onCB);
};
return callable;
}
+8
View File
@@ -0,0 +1,8 @@
export function derivative(f: (x: number) => number) {
const h = 0.001;
return (x: number) => (f(x + h) - f(x - h)) / (2 * h);
}
export function getVelocity(f: (t: number) => number): (t: number) => number {
return derivative(f);
}
+4
View File
@@ -0,0 +1,4 @@
export const eqSet: <T>(xs: Set<T>, ys: Set<T>) => boolean = (
xs,
ys,
): boolean => xs.size === ys.size && [...xs].every((x) => ys.has(x));
+59
View File
@@ -0,0 +1,59 @@
const SAFE_FLAG_CHUNK_SIZE = Number.MAX_SAFE_INTEGER.toString(2).length;
export class FlagSets {
private flags: number[] = [];
private _size = 0;
get size() {
return this._size;
}
[Symbol.iterator]() {
let flag = 0;
let chunkIndex = 0;
let chunkOffset = 0;
return {
next: () => {
while (chunkIndex < this.flags.length) {
while (chunkOffset < SAFE_FLAG_CHUNK_SIZE) {
const flagBit = 1 << chunkOffset;
if (this.flags[chunkIndex] & flagBit) {
return { value: flag, done: false };
}
flag++;
chunkOffset++;
}
chunkIndex++;
chunkOffset = 0;
}
return { value: undefined, done: true };
},
};
}
add(flag: number) {
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
const flagBit = 1 << chunkOffset;
if (!(this.flags[chunkIndex] & flagBit)) {
this._size++;
this.flags[chunkIndex] |= flagBit;
}
}
delete(flag: number) {
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
const flagBit = 1 << chunkOffset;
if (!(this.flags[chunkIndex] & flagBit)) {
this._size--;
this.flags[chunkIndex] &= ~(1 << chunkOffset);
}
}
has(flag: number) {
const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0;
const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE;
return (this.flags[chunkIndex] & (1 << chunkOffset)) !== 0;
}
}
+3
View File
@@ -0,0 +1,3 @@
export const isCJK = (char: string) => {
return /^[\p{Unified_Ideograph}\u0800-\u9FFC]+$/u.test(char);
};
+120
View File
@@ -0,0 +1,120 @@
type seconds = number;
export interface LinearParams {
duration: number; // Duration of the transition in seconds
}
export class Linear {
private currentPosition = 0;
private targetPosition = 0;
private currentTime = 0;
private params: Partial<LinearParams> = {};
private currentSolver: (t: seconds) => number;
private startTime = 0;
private queueParams:
| (Partial<LinearParams> & {
time: number;
})
| undefined;
private queuePosition:
| {
time: number;
position: number;
}
| undefined;
constructor(currentPosition = 0) {
this.targetPosition = currentPosition;
this.currentPosition = this.targetPosition;
this.currentSolver = () => this.targetPosition;
}
private resetSolver() {
this.currentTime = 0;
this.startTime = 0;
this.currentSolver = solveLinear(
this.currentPosition,
this.targetPosition,
this.params,
);
}
arrived() {
return (
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
this.queueParams === undefined &&
this.queuePosition === undefined
);
}
setPosition(targetPosition: number) {
this.targetPosition = targetPosition;
this.currentPosition = targetPosition;
this.currentSolver = () => this.targetPosition;
}
update(delta = 0) {
this.currentTime += delta;
this.currentPosition = this.currentSolver(
this.currentTime - this.startTime,
);
if (this.queueParams) {
this.queueParams.time -= delta;
if (this.queueParams.time <= 0) {
this.updateParams({
...this.queueParams,
});
}
}
if (this.queuePosition) {
this.queuePosition.time -= delta;
if (this.queuePosition.time <= 0) {
this.setTargetPosition(this.queuePosition.position);
}
}
if (this.arrived()) {
this.setPosition(this.targetPosition);
}
}
updateParams(params: Partial<LinearParams>, delay = 0) {
if (delay > 0) {
this.queueParams = {
...(this.queuePosition ?? {}),
...params,
time: delay,
};
} else {
this.queuePosition = undefined;
this.params = {
...this.params,
...params,
};
this.resetSolver();
}
}
setTargetPosition(targetPosition: number, delay = 0) {
if (delay > 0) {
this.queuePosition = {
...(this.queuePosition ?? {}),
position: targetPosition,
time: delay,
};
} else {
this.queuePosition = undefined;
this.targetPosition = targetPosition;
this.resetSolver();
}
}
getCurrentPosition() {
return this.currentPosition;
}
}
function solveLinear(
from: number,
to: number,
params?: Partial<LinearParams>,
): (t: seconds) => number {
const duration = params?.duration ?? 1;
const delta = to - from;
return (t: seconds) => {
if (t < 0) return from;
if (t > duration) return to;
return from + (delta * t) / duration;
};
}
+144
View File
@@ -0,0 +1,144 @@
import type { LyricWord } from "../interfaces.ts";
import { isCJK } from "./is-cjk.ts";
const hasSegmenter =
typeof Intl !== "undefined" && typeof Intl.Segmenter !== "undefined";
/**
* 将输入的单词重新分组,之间没有空格的单词将会组合成一个单词数组
*
* 例如输入:`["Life", " ", "is", " a", " su", "gar so", "sweet"]`
*
* 应该返回:`["Life", " ", "is", " a", [" su", "gar"], "so", "sweet"]`
* @param words 输入的单词数组
* @returns 重新分组后的单词数组
*/
export function chunkAndSplitLyricWords(
words: LyricWord[],
): (LyricWord | LyricWord[])[] {
const atoms: LyricWord[] = [];
for (const w of words) {
const content = w.word.trim();
const isSpace = content.length === 0;
if (isSpace) {
atoms.push({ ...w });
continue;
}
const parts = w.word.split(/(\s+)/).filter((p) => p.length > 0);
let currentOffset = 0;
const totalLength = w.word.replace(/\s/g, "").length || 1;
for (const part of parts) {
if (!part.trim()) {
const startTime =
w.startTime +
(currentOffset / totalLength) * (w.endTime - w.startTime);
atoms.push({
word: part,
romanWord: "",
startTime: startTime,
endTime: startTime,
obscene: w.obscene,
});
continue;
}
if (isCJK(part) && part.length > 1 && !w.romanWord) {
const chars = part.split("");
for (const char of chars) {
const charDuration = (1 / totalLength) * (w.endTime - w.startTime);
const startTime =
w.startTime +
(currentOffset / totalLength) * (w.endTime - w.startTime);
atoms.push({
word: char,
romanWord: "",
startTime: startTime,
endTime: startTime + charDuration,
obscene: w.obscene,
});
currentOffset += 1;
}
} else {
const partRealLen = part.length;
const duration =
(partRealLen / totalLength) * (w.endTime - w.startTime);
const startTime =
w.startTime +
(currentOffset / totalLength) * (w.endTime - w.startTime);
atoms.push({
word: part,
romanWord: w.romanWord,
startTime: startTime,
endTime: startTime + duration,
obscene: w.obscene,
});
currentOffset += partRealLen;
}
}
}
if (!hasSegmenter) {
return atoms;
}
const fullText = atoms.map((a) => a.word).join("");
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
const segments = Array.from(segmenter.segment(fullText));
const result: (LyricWord | LyricWord[])[] = [];
let atomIndex = 0;
let currentAtomOffset = 0;
let currentSegmentOffset = 0;
for (const segment of segments) {
const segmentStr = segment.segment;
const segmentLen = segmentStr.length;
if (currentAtomOffset > currentSegmentOffset) {
currentSegmentOffset += segmentLen;
continue;
}
const segmentGroup: LyricWord[] = [];
let neededLength = segmentLen;
while (neededLength > 0 && atomIndex < atoms.length) {
const currentAtom = atoms[atomIndex];
const atomLen = currentAtom.word.length;
segmentGroup.push(currentAtom);
currentAtomOffset += atomLen;
atomIndex++;
neededLength -= atomLen;
}
currentSegmentOffset += segmentLen;
while (segmentGroup.length > 1 && !segmentGroup[0].word.trim()) {
const spaceAtom = segmentGroup.shift();
if (spaceAtom) {
result.push(spaceAtom);
}
}
if (segmentGroup.length === 1) {
result.push(segmentGroup[0]);
} else if (segmentGroup.length > 1) {
result.push(segmentGroup);
}
}
while (atomIndex < atoms.length) {
result.push(atoms[atomIndex++]);
}
return result;
}
+47
View File
@@ -0,0 +1,47 @@
// biome-ignore format: matrix
export type Matrix4 = [
number, number, number, number,
number, number, number, number,
number, number, number, number,
number, number, number, number,
];
export function createMatrix4(): Matrix4 {
// biome-ignore format: matrix
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
export function scaleMatrix4(
m: Matrix4,
scale = 1,
origin = { x: 0, y: 0 },
): Matrix4 {
const [ox, oy] = [origin.x, origin.y];
// biome-ignore format: matrix
return [
m[0] * scale , m[1] * scale , m[2] * scale , m[3],
m[4] * scale , m[5] * scale , m[6] * scale , m[7],
m[8] * scale , m[9] * scale , m[10] * scale, m[11],
m[12] - ox * scale + ox, m[13] - oy * scale + oy, m[14] , m[15]
];
}
export function translateMatrix4(m: Matrix4, x = 0, y = 0, z = 0): Matrix4 {
// biome-ignore format: matrix
return [
m[0] , m[1] , m[2] , m[3] ,
m[4] , m[5] , m[6] , m[7] ,
m[8] , m[9] , m[10] , m[11],
m[12] + x, m[13] + y, m[14] + z, m[15]
];
}
export function matrix4ToCSS(m: Matrix4, fractionDigits = 4): string {
const format = (n: number, _: number) => n.toFixed(fractionDigits);
return `matrix3d(${m.map(format).join(", ")})`;
}
+40
View File
@@ -0,0 +1,40 @@
// 一个异步互斥锁,用于控制并发
export function mutexifyFunction<
F extends (...args: Args) => Promise<Result>,
Args extends any[],
Result,
>(func: F): F {
const awaitingTasks: {
resolve: (value: Result) => void;
reject: (reason: any) => void;
args: Args;
}[] = [];
function processNextTask() {
const task = awaitingTasks[0];
if (!task) return;
func(...task.args)
.then((value) => {
task.resolve(value);
})
.catch((reason) => {
task.reject(reason);
})
.finally(() => {
awaitingTasks.shift();
if (awaitingTasks.length > 0) {
processNextTask();
}
});
}
return ((...args: Args) => {
return new Promise<Result>((resolve, reject) => {
awaitingTasks.push({ resolve, reject, args });
if (awaitingTasks.length === 1) {
processNextTask();
}
});
}) as F;
}
export default mutexifyFunction;
+182
View File
@@ -0,0 +1,182 @@
import type { LyricLine } from "../interfaces.ts";
/**
* 将行级时间戳强行设为字级时间戳
*/
function resetLineTimestamps(lines: LyricLine[]) {
for (const line of lines) {
if (line.words.length > 0) {
const firstWord = line.words[0];
const lastWord = line.words[line.words.length - 1];
line.startTime = firstWord.startTime;
line.endTime = lastWord.endTime;
}
}
}
/**
* 把多行背景人声转换为单行背景人声 + 主歌词行的形式
*/
function convertExcessiveBackgroundLines(lines: LyricLine[]) {
let consecutiveBgCount = 0;
for (const line of lines) {
if (line.isBG) {
consecutiveBgCount++;
if (consecutiveBgCount > 1) {
line.isBG = false;
}
} else {
consecutiveBgCount = 0;
}
}
}
/**
* 同步主歌词与背景人声的时间
*
* 取两者中最早的开始时间和最晚的结束时间,应用给双方
*/
function syncMainAndBackgroundLines(lines: LyricLine[]) {
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line.isBG) continue;
const nextLine = lines[i + 1];
if (nextLine?.isBG) {
const allWords = [...line.words, ...nextLine.words].filter(
(w) => w.word.trim().length > 0,
);
if (allWords.length > 0) {
const minStart = Math.min(...allWords.map((w) => w.startTime));
const maxEnd = Math.max(...allWords.map((w) => w.endTime));
const finalStart = Math.min(
minStart,
line.startTime,
nextLine.startTime,
);
const finalEnd = Math.max(maxEnd, line.endTime, nextLine.endTime);
line.startTime = finalStart;
line.endTime = finalEnd;
nextLine.startTime = finalStart;
nextLine.endTime = finalEnd;
}
}
}
}
/**
* 清洗非刻意的重叠
*
* 如果重叠大于100ms 且 重叠超过下一行时长的10%,则截断
*/
function cleanUnintentionalOverlaps(lines: LyricLine[]) {
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (line.isBG) continue;
let nextMainIndex = i + 1;
while (nextMainIndex < lines.length && lines[nextMainIndex].isBG) {
nextMainIndex++;
}
if (nextMainIndex < lines.length) {
const nextLine = lines[nextMainIndex];
const overlap = line.endTime - nextLine.startTime;
if (overlap > 0) {
const nextDuration = nextLine.endTime - nextLine.startTime;
const percentageThreshold = nextDuration * 0.1;
// 重叠大于100ms 且 重叠超过下一行时长的10%
const isIntentionalOverlap =
overlap > 100 && overlap > percentageThreshold;
if (!isIntentionalOverlap) {
line.endTime = nextLine.startTime;
const attachedBgLine = lines[i + 1];
if (attachedBgLine?.isBG) {
attachedBgLine.endTime = nextLine.startTime;
}
}
}
}
}
}
/**
* 尝试让歌词提前最多 1 秒开始,如果有重叠则尝试最多提前 400ms 或上一行时长的 30%
*/
function tryAdvanceStartTime(lines: LyricLine[]) {
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line.isBG) continue;
let prevLine: LyricLine | null = null;
if (i > 0) {
let prevIdx = i - 1;
if (lines[prevIdx].isBG) {
prevIdx--;
}
if (prevIdx >= 0) {
prevLine = lines[prevIdx];
}
}
let targetAdvanceAmount = 0;
let safeBoundary = 0;
if (prevLine) {
const originallyHadGap = line.startTime >= prevLine.endTime;
if (originallyHadGap) {
targetAdvanceAmount = 1000;
safeBoundary = prevLine.endTime;
} else {
targetAdvanceAmount = 400;
const prevDuration = prevLine.endTime - prevLine.startTime;
safeBoundary = prevLine.startTime + prevDuration * 0.3;
}
} else {
targetAdvanceAmount = 1000;
safeBoundary = 0;
}
const targetTime = line.startTime - targetAdvanceAmount;
const newStartTime = Math.max(safeBoundary, targetTime);
if (newStartTime < line.startTime) {
line.startTime = newStartTime;
}
const nextLine = lines[i + 1];
if (nextLine?.isBG) {
nextLine.startTime = line.startTime;
}
}
}
/**
* 优化歌词行的展示效果
*
* 注意会直接原地修改入参,确保你已经提前深克隆了歌词行数组
* @param lines 歌词行数组
*/
export function optimizeLyricLines(lines: LyricLine[]) {
for (const line of lines) {
for (const word of line.words) {
word.word = word.word.replace(/\s+/g, " ");
}
}
resetLineTimestamps(lines);
convertExcessiveBackgroundLines(lines);
syncMainAndBackgroundLines(lines);
cleanUnintentionalOverlaps(lines);
tryAdvanceStartTime(lines);
}
+79
View File
@@ -0,0 +1,79 @@
export function loadImage(imageUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = document.createElement("img");
img.onload = () => resolve(img);
img.onerror = reject;
img.src = imageUrl;
img.crossOrigin = "anonymous";
img.loading = "eager";
});
}
export function loadVideo(videoUrl: string): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
let playing = false;
let timeupdate = false;
let rejected = false;
video.addEventListener(
"playing",
() => {
playing = true;
checkReady();
},
true,
);
video.addEventListener(
"timeupdate",
() => {
timeupdate = true;
checkReady();
},
true,
);
video.addEventListener(
"error",
(err) => {
rejected = true;
reject(err);
},
true,
);
function checkReady() {
if (playing && timeupdate && !rejected) {
resolve(video);
}
}
video.src = videoUrl;
video.playsInline = true;
video.crossOrigin = "anonymous";
video.autoplay = true;
video.loop = true;
video.muted = true;
video.play();
});
}
export function loadResourceFromUrl(
url: string,
isVideo = false,
): Promise<HTMLImageElement | HTMLVideoElement> {
return isVideo ? loadVideo(url) : loadImage(url);
}
export function loadResourceFromElement(
element: HTMLImageElement | HTMLVideoElement,
): Promise<HTMLImageElement | HTMLVideoElement> {
return new Promise((resolve, reject) => {
if (
element instanceof HTMLImageElement
? element.complete
: element.readyState >= 3
) {
resolve(element);
} else {
element.onload = () => resolve(element);
element.onerror = reject;
}
});
}
+73
View File
@@ -0,0 +1,73 @@
/**
* @fileoverview
* @see https://github.com/wilsonpage/fastdom/blob/master/fastdom.js
*/
interface Task<T> {
task: () => T;
resolve: (value: T) => void;
reject: (reason?: any) => void;
}
const measureTasks: Task<any>[] = [];
const mutateTasks: Task<any>[] = [];
let scheduled = false;
function onFlush() {
let tmp = mutateTasks.shift();
while (tmp) {
try {
tmp.resolve(tmp.task());
} catch (error) {
tmp.reject(error);
}
tmp = mutateTasks.shift();
}
tmp = measureTasks.shift();
while (tmp) {
try {
tmp.resolve(tmp.task());
} catch (error) {
tmp.reject(error);
}
tmp = measureTasks.shift();
}
scheduled = false;
}
function scheduleFlush() {
if (!scheduled) {
scheduled = true;
requestAnimationFrame(onFlush);
}
}
export function measure<T>(callback: () => T): Promise<T> {
const task: Task<T> = {
task: callback,
resolve: () => {},
reject: () => {},
};
const promise = new Promise<T>((resolve, reject) => {
task.resolve = resolve;
task.reject = reject;
});
measureTasks.push(task);
scheduleFlush();
return promise;
}
export function mutate(callback: () => void) {
const task: Task<void> = {
task: callback,
resolve: () => {},
reject: () => {},
};
const promise = new Promise((resolve, reject) => {
task.resolve = resolve;
task.reject = reject;
});
mutateTasks.push(task);
scheduleFlush();
return promise;
}
+158
View File
@@ -0,0 +1,158 @@
import { getVelocity } from "./derivative.ts";
/** MIT License github.com/pushkine/ */
export interface SpringParams {
mass: number; // = 1.0
damping: number; // = 10.0
stiffness: number; // = 100.0
soft: boolean; // = false
}
type seconds = number;
export class Spring {
private currentPosition = 0;
private targetPosition = 0;
private currentTime = 0;
private params: Partial<SpringParams> = {};
private currentSolver: (t: seconds) => number;
private getV: (t: seconds) => number;
private getV2: (t: seconds) => number;
private queueParams:
| (Partial<SpringParams> & {
time: number;
})
| undefined;
private queuePosition:
| {
time: number;
position: number;
}
| undefined;
constructor(currentPosition = 0) {
this.targetPosition = currentPosition;
this.currentPosition = this.targetPosition;
this.currentSolver = () => this.targetPosition;
this.getV = () => 0;
this.getV2 = () => 0;
}
private resetSolver() {
const curV = this.getV(this.currentTime);
this.currentTime = 0;
this.currentSolver = solveSpring(
this.currentPosition,
curV,
this.targetPosition,
0,
this.params,
);
this.getV = getVelocity(this.currentSolver);
this.getV2 = getVelocity(this.getV);
}
arrived() {
return (
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
this.getV(this.currentTime) < 0.01 &&
this.getV2(this.currentTime) < 0.01 &&
this.queueParams === undefined &&
this.queuePosition === undefined
);
}
setPosition(targetPosition: number) {
this.targetPosition = targetPosition;
this.currentPosition = targetPosition;
this.currentSolver = () => this.targetPosition;
this.getV = () => 0;
this.getV2 = () => 0;
}
update(delta = 0) {
this.currentTime += delta;
this.currentPosition = this.currentSolver(this.currentTime);
if (this.queueParams) {
this.queueParams.time -= delta;
if (this.queueParams.time <= 0) {
this.updateParams({
...this.queueParams,
});
}
}
if (this.queuePosition) {
this.queuePosition.time -= delta;
if (this.queuePosition.time <= 0) {
this.setTargetPosition(this.queuePosition.position);
}
}
if (this.arrived()) {
this.setPosition(this.targetPosition);
}
}
updateParams(params: Partial<SpringParams>, delay = 0) {
if (delay > 0) {
this.queueParams = {
...(this.queuePosition ?? {}),
...params,
time: delay,
};
} else {
this.queuePosition = undefined;
this.params = {
...this.params,
...params,
};
this.resetSolver();
}
}
setTargetPosition(targetPosition: number, delay = 0) {
if (delay > 0) {
this.queuePosition = {
...(this.queuePosition ?? {}),
position: targetPosition,
time: delay,
};
} else {
this.queuePosition = undefined;
this.targetPosition = targetPosition;
this.resetSolver();
}
}
getCurrentPosition() {
return this.currentPosition;
}
}
function solveSpring(
from: number,
velocity: number,
to: number,
delay: seconds = 0,
params?: Partial<SpringParams>,
): (t: seconds) => number {
const soft = params?.soft ?? false;
const stiffness = params?.stiffness ?? 100;
const damping = params?.damping ?? 10;
const mass = params?.mass ?? 1;
const delta = to - from;
if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) {
const angular_frequency = -Math.sqrt(stiffness / mass);
const leftover = -angular_frequency * delta - velocity;
return (t: seconds) => {
t -= delay;
if (t < 0) return from;
return to - (delta + t * leftover) * Math.E ** (t * angular_frequency);
};
}
const damping_frequency = Math.sqrt(4.0 * mass * stiffness - damping ** 2.0);
const leftover =
(damping * delta - 2.0 * mass * velocity) / damping_frequency;
const dfm = (0.5 * damping_frequency) / mass;
const dm = -(0.5 * damping) / mass;
return (t: seconds) => {
t -= delay;
if (t < 0) return from;
return (
to -
(Math.cos(t * dfm) * delta + Math.sin(t * dfm) * leftover) *
Math.E ** (t * dm)
);
};
}
+139
View File
@@ -0,0 +1,139 @@
import bezier from "bezier-easing";
import type { Disposable } from "../interfaces.ts";
import { getVelocity } from "./derivative";
// export interface SpringParams {
// mass: number; // = 1.0
// damping: number; // = 10.0
// stiffness: number; // = 100.0
// }
type seconds = number;
type CSSStyleKeys = {
[Style in keyof CSSStyleDeclaration]: Style extends string
? CSSStyleDeclaration[Style] extends string
? Style
: never
: never;
}[keyof CSSStyleDeclaration];
/**
* 基于 Web Animation API 的弹簧动画工具类,效果上可能逊于实时演算的版本
*/
export class WebAnimationSpring extends EventTarget implements Disposable {
private currentAnimation: Animation;
private targetPosition = 0;
private isStatic = true;
// private params: Partial<SpringParams> = {};
private currentSolver: (t: seconds) => number = () => this.targetPosition;
private getV: (t: seconds) => number = () => 0;
constructor(
private element: HTMLElement,
private styleName: CSSStyleKeys,
private valueGenerator: (value: number) => string,
private currentPosition = 0,
) {
super();
this.targetPosition = currentPosition;
this.currentAnimation = element.animate(
[
{
[styleName]: valueGenerator(currentPosition),
},
],
{
duration: 1000,
fill: "both",
composite: "add",
},
);
}
makeStatic() {
this.getV = () => 0;
this.currentSolver = () => this.targetPosition;
this.currentAnimation.cancel();
this.currentAnimation = this.element.animate(
[
{
[this.styleName]: this.valueGenerator(this.targetPosition),
},
{
[this.styleName]: this.valueGenerator(this.targetPosition),
},
],
{
duration: Number.POSITIVE_INFINITY,
id: `wa-spring-static-${this.styleName}`,
fill: "both",
easing: "cubic-bezier(0.5, 0, 0.5, 1)",
composite: "add",
},
);
this.currentAnimation.pause();
}
setTargetPosition(targetPosition: number) {
this.targetPosition = targetPosition;
this.onStepFinished();
}
getCurrentPosition() {
if (this.isStatic || !this.currentAnimation.effect)
return this.currentPosition;
const timing = this.currentAnimation.effect?.getComputedTiming();
return this.currentSolver(timing.progress ?? 1);
}
getCurrentVelocity() {
if (this.isStatic || !this.currentAnimation.effect) return 0;
const timing = this.currentAnimation.effect?.getComputedTiming();
return this.getV(timing.progress ?? 1);
}
private onStepFinished() {
const currentPosition = this.getCurrentPosition();
if (Math.abs(this.targetPosition - currentPosition) < 0.0001) {
this.makeStatic();
this.dispatchEvent(new Event("finished"));
return;
}
this.currentSolver = bezier(0.5, 0, 0.5, 1);
this.getV = getVelocity(this.currentSolver);
this.currentAnimation.cancel();
const delta = (this.targetPosition - currentPosition) * 1.05;
this.currentPosition += delta;
this.currentAnimation = this.element.animate(
[
{
[this.styleName]: this.valueGenerator(currentPosition),
},
{
[this.styleName]: this.valueGenerator(this.currentPosition),
},
],
{
duration: 250,
id: `wa-spring-dynamic-${this.styleName}`,
fill: "forwards",
easing: "cubic-bezier(0.5, 0, 0.5, 1)",
composite: "add",
},
);
this.currentAnimation.onfinish = () => this.onStepFinished();
}
stop() {
if (this.currentAnimation) {
this.currentAnimation.cancel();
}
}
dispose() {
this.stop();
}
}