初次提交
This commit is contained in:
+30
@@ -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
@@ -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);
|
||||
}
|
||||
Vendored
+4
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export const isCJK = (char: string) => {
|
||||
return /^[\p{Unified_Ideograph}\u0800-\u9FFC]+$/u.test(char);
|
||||
};
|
||||
Vendored
+120
@@ -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
@@ -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;
|
||||
}
|
||||
Vendored
+47
@@ -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(", ")})`;
|
||||
}
|
||||
Vendored
+40
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Vendored
+158
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user