467 lines
14 KiB
JavaScript
467 lines
14 KiB
JavaScript
import { apiJson } from "./core.js";
|
|
import { animationPreviewFrameAtElapsed } from "./animation-ui-model.js";
|
|
|
|
export const ANIMATION_PREVIEW_PAGE_SIZE = 500;
|
|
export const ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT = 2;
|
|
export const ANIMATION_PREVIEW_MIN_MAX_CONCURRENT = 1;
|
|
export const ANIMATION_PREVIEW_MAX_MAX_CONCURRENT = 50;
|
|
export const ANIMATION_PREVIEW_PRELOAD_COUNT = 3;
|
|
|
|
export function normalizeAnimationPreviewMaxConcurrent(value) {
|
|
if (!Number.isInteger(value)
|
|
|| value < ANIMATION_PREVIEW_MIN_MAX_CONCURRENT
|
|
|| value > ANIMATION_PREVIEW_MAX_MAX_CONCURRENT) {
|
|
throw new Error(
|
|
`动态缩略图并发数量必须是 ${ANIMATION_PREVIEW_MIN_MAX_CONCURRENT} 到 ${ANIMATION_PREVIEW_MAX_MAX_CONCURRENT} 之间的整数`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function stalePreviewError() {
|
|
const error = new Error("动图已在预览加载期间发生变化");
|
|
error.code = "ANIMATION_PREVIEW_STALE";
|
|
return error;
|
|
}
|
|
|
|
export async function loadCompleteAnimationPreview(
|
|
animation,
|
|
{ fetchJson = apiJson, signal, pageSize = ANIMATION_PREVIEW_PAGE_SIZE } = {},
|
|
) {
|
|
const expectedRevision = String(animation?.revision || "");
|
|
const expectedCount = Number(animation?.frame_count || 0);
|
|
if (!animation?.id || !expectedRevision) throw new Error("动图预览缺少标识或修订值");
|
|
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > ANIMATION_PREVIEW_PAGE_SIZE) {
|
|
throw new Error("动图预览分页大小无效");
|
|
}
|
|
if (expectedCount === 0) return [];
|
|
|
|
const frames = [];
|
|
let offset = 0;
|
|
let total = expectedCount;
|
|
while (offset < total) {
|
|
const query = new URLSearchParams({
|
|
frame_offset: String(offset),
|
|
frame_limit: String(pageSize),
|
|
});
|
|
const page = await fetchJson(`/api/animations/${animation.id}?${query}`, { signal });
|
|
if (String(page?.revision || "") !== expectedRevision) throw stalePreviewError();
|
|
if (!Array.isArray(page.frames)) throw new Error("动图预览帧列表无效");
|
|
total = Number(page.frame_total ?? page.frame_count ?? page.frames.length);
|
|
if (!Number.isInteger(total) || total < 0 || total !== expectedCount) throw stalePreviewError();
|
|
if (!page.frames.length && offset < total) throw new Error("动图预览分页提前结束");
|
|
frames.push(...page.frames);
|
|
offset += page.frames.length;
|
|
}
|
|
if (frames.length !== expectedCount) throw stalePreviewError();
|
|
return frames;
|
|
}
|
|
|
|
class AnimationPreviewEngine {
|
|
constructor() {
|
|
this.records = new Set();
|
|
this.initialQueue = [];
|
|
this.activeInitialLoads = 0;
|
|
this.queue = [];
|
|
this.activeLoads = 0;
|
|
this.maxConcurrent = ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT;
|
|
this.timer = null;
|
|
this.cache = new Map();
|
|
this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
this.observer = typeof IntersectionObserver === "function"
|
|
? new IntersectionObserver((entries) => this.handleIntersections(entries), { rootMargin: "0px" })
|
|
: null;
|
|
this.viewportSyncPending = false;
|
|
this.handleFallbackViewport = () => {
|
|
if (this.observer || this.viewportSyncPending) return;
|
|
this.viewportSyncPending = true;
|
|
window.requestAnimationFrame(() => {
|
|
this.viewportSyncPending = false;
|
|
this.syncAll();
|
|
});
|
|
};
|
|
this.handleVisibility = () => this.syncAll();
|
|
this.handleMotionChange = () => this.syncAll();
|
|
document.addEventListener("visibilitychange", this.handleVisibility);
|
|
this.reducedMotion.addEventListener?.("change", this.handleMotionChange);
|
|
window.addEventListener("scroll", this.handleFallbackViewport, true);
|
|
window.addEventListener("resize", this.handleFallbackViewport);
|
|
}
|
|
|
|
cacheKey(animation) {
|
|
return `${animation.id}:${animation.revision}`;
|
|
}
|
|
|
|
setMaxConcurrent(value) {
|
|
const normalized = normalizeAnimationPreviewMaxConcurrent(value);
|
|
if (normalized === this.maxConcurrent) return normalized;
|
|
this.maxConcurrent = normalized;
|
|
this.syncAll();
|
|
return normalized;
|
|
}
|
|
|
|
createGroup() {
|
|
const engine = this;
|
|
const records = new Set();
|
|
return {
|
|
active: false,
|
|
register(options) {
|
|
const record = engine.register(this, options);
|
|
records.add(record);
|
|
return () => {
|
|
records.delete(record);
|
|
engine.disposeRecord(record);
|
|
engine.syncAll();
|
|
};
|
|
},
|
|
setActive(active) {
|
|
this.active = Boolean(active);
|
|
engine.syncAll();
|
|
},
|
|
clear() {
|
|
const keys = new Set();
|
|
for (const record of records) {
|
|
keys.add(engine.cacheKey(record.animation));
|
|
engine.disposeRecord(record);
|
|
}
|
|
records.clear();
|
|
for (const key of keys) engine.cache.delete(key);
|
|
engine.scheduleTick();
|
|
},
|
|
};
|
|
}
|
|
|
|
register(group, {
|
|
animation,
|
|
image,
|
|
initialUrl,
|
|
target = image,
|
|
onFrame = null,
|
|
onLoad = null,
|
|
onInitialError = null,
|
|
}) {
|
|
const record = {
|
|
group,
|
|
animation,
|
|
image,
|
|
initialUrl,
|
|
target,
|
|
onFrame,
|
|
onLoad,
|
|
onInitialError,
|
|
visible: false,
|
|
disposed: false,
|
|
initialQueued: false,
|
|
initialLoading: false,
|
|
initialReady: false,
|
|
initialFailed: false,
|
|
queued: false,
|
|
loading: false,
|
|
loadController: null,
|
|
schedule: null,
|
|
startedAt: null,
|
|
currentIndex: -1,
|
|
preloads: new Map(),
|
|
lastSuccessfulUrl: "",
|
|
restoring: false,
|
|
};
|
|
record.handleLoad = () => {
|
|
if (record.initialLoading) this.finishInitialLoad(record, true);
|
|
record.lastSuccessfulUrl = image.currentSrc || image.src;
|
|
record.restoring = false;
|
|
onLoad?.();
|
|
};
|
|
record.handleError = () => {
|
|
if (record.initialLoading) {
|
|
this.finishInitialLoad(record, false);
|
|
record.restoring = false;
|
|
onInitialError?.();
|
|
return;
|
|
}
|
|
const failedUrl = image.currentSrc || image.src;
|
|
if (record.lastSuccessfulUrl && failedUrl !== record.lastSuccessfulUrl && !record.restoring) {
|
|
record.restoring = true;
|
|
image.src = record.lastSuccessfulUrl;
|
|
return;
|
|
}
|
|
record.restoring = false;
|
|
onInitialError?.();
|
|
};
|
|
image.addEventListener("load", record.handleLoad);
|
|
image.addEventListener("error", record.handleError);
|
|
this.records.add(record);
|
|
this.observer?.observe(target);
|
|
this.syncAll();
|
|
return record;
|
|
}
|
|
|
|
disposeRecord(record) {
|
|
if (record.disposed) return;
|
|
record.disposed = true;
|
|
record.initialQueued = false;
|
|
if (record.initialLoading) {
|
|
record.initialLoading = false;
|
|
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
|
|
record.image.removeAttribute("src");
|
|
}
|
|
record.loadController?.abort();
|
|
record.preloads.clear();
|
|
record.image.removeEventListener("load", record.handleLoad);
|
|
record.image.removeEventListener("error", record.handleError);
|
|
this.observer?.unobserve(record.target);
|
|
this.records.delete(record);
|
|
this.pumpInitialQueue();
|
|
}
|
|
|
|
canLoadInitial(record) {
|
|
return !record.disposed
|
|
&& record.group.active
|
|
&& this.isRecordVisible(record)
|
|
&& document.visibilityState === "visible"
|
|
&& Boolean(record.initialUrl);
|
|
}
|
|
|
|
isRecordVisible(record) {
|
|
if (this.observer) return record.visible;
|
|
const rect = record.target.getBoundingClientRect();
|
|
return rect.width > 0
|
|
&& rect.height > 0
|
|
&& rect.right > 0
|
|
&& rect.bottom > 0
|
|
&& rect.left < window.innerWidth
|
|
&& rect.top < window.innerHeight;
|
|
}
|
|
|
|
syncInitial(record) {
|
|
if (!this.canLoadInitial(record)
|
|
|| record.initialReady
|
|
|| record.initialFailed
|
|
|| record.initialLoading
|
|
|| record.initialQueued) return;
|
|
record.initialQueued = true;
|
|
this.initialQueue.push(record);
|
|
}
|
|
|
|
pumpInitialQueue() {
|
|
while (this.activeInitialLoads < this.maxConcurrent
|
|
&& this.initialQueue.length) {
|
|
const record = this.initialQueue.shift();
|
|
record.initialQueued = false;
|
|
if (!this.canLoadInitial(record) || record.initialReady || record.initialFailed) continue;
|
|
record.initialLoading = true;
|
|
this.activeInitialLoads += 1;
|
|
record.image.src = record.initialUrl;
|
|
}
|
|
}
|
|
|
|
finishInitialLoad(record, succeeded) {
|
|
if (!record.initialLoading) return;
|
|
record.initialLoading = false;
|
|
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
|
|
record.initialReady = succeeded;
|
|
record.initialFailed = !succeeded;
|
|
this.pumpInitialQueue();
|
|
this.syncAll();
|
|
}
|
|
|
|
handleIntersections(entries) {
|
|
for (const entry of entries) {
|
|
for (const record of this.records) {
|
|
if (record.target !== entry.target) continue;
|
|
record.visible = entry.isIntersecting;
|
|
}
|
|
}
|
|
this.syncAll();
|
|
}
|
|
|
|
canRun(record) {
|
|
return !record.disposed
|
|
&& record.group.active
|
|
&& this.isRecordVisible(record)
|
|
&& document.visibilityState === "visible"
|
|
&& !this.reducedMotion.matches
|
|
&& Number(record.animation.frame_count) > 1;
|
|
}
|
|
|
|
shouldRun(record) {
|
|
let selected = 0;
|
|
for (const candidate of this.records) {
|
|
if (!this.canRun(candidate)) continue;
|
|
if (candidate === record) return selected < this.maxConcurrent;
|
|
selected += 1;
|
|
if (selected >= this.maxConcurrent) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
syncRecord(record) {
|
|
if (!this.shouldRun(record)) {
|
|
record.loadController?.abort();
|
|
record.loadController = null;
|
|
record.loading = false;
|
|
record.queued = false;
|
|
record.preloads.clear();
|
|
if (record.schedule) this.cache.delete(this.cacheKey(record.animation));
|
|
record.schedule = null;
|
|
record.startedAt = null;
|
|
record.currentIndex = -1;
|
|
return;
|
|
}
|
|
if (!record.initialReady) return;
|
|
if (record.schedule) return;
|
|
const cached = this.cache.get(this.cacheKey(record.animation));
|
|
if (cached) {
|
|
record.schedule = cached;
|
|
record.startedAt = performance.now();
|
|
return;
|
|
}
|
|
if (!record.loading && !record.queued) {
|
|
record.queued = true;
|
|
this.queue.push(record);
|
|
this.pumpQueue();
|
|
}
|
|
}
|
|
|
|
syncAll() {
|
|
for (const record of this.records) this.syncInitial(record);
|
|
this.pumpInitialQueue();
|
|
for (const record of this.records) this.syncRecord(record);
|
|
this.scheduleTick();
|
|
}
|
|
|
|
pumpQueue() {
|
|
while (this.activeLoads < this.maxConcurrent && this.queue.length) {
|
|
const record = this.queue.shift();
|
|
record.queued = false;
|
|
if (!this.shouldRun(record) || record.disposed || record.schedule) continue;
|
|
this.loadRecord(record);
|
|
}
|
|
}
|
|
|
|
async loadRecord(record) {
|
|
record.loading = true;
|
|
record.loadController = new AbortController();
|
|
this.activeLoads += 1;
|
|
try {
|
|
const frames = await loadCompleteAnimationPreview(record.animation, {
|
|
signal: record.loadController.signal,
|
|
});
|
|
if (record.disposed || !this.shouldRun(record)) return;
|
|
record.schedule = frames;
|
|
record.startedAt = performance.now();
|
|
this.cache.set(this.cacheKey(record.animation), frames);
|
|
} catch (error) {
|
|
if (error?.name !== "AbortError" && error?.code !== "ANIMATION_PREVIEW_STALE") {
|
|
// The first frame remains usable; a later refresh can retry the full schedule.
|
|
}
|
|
} finally {
|
|
record.loading = false;
|
|
record.loadController = null;
|
|
this.activeLoads -= 1;
|
|
this.pumpQueue();
|
|
this.scheduleTick();
|
|
}
|
|
}
|
|
|
|
preloadAhead(record, index) {
|
|
const wanted = new Set();
|
|
const length = record.schedule.length;
|
|
for (let step = 1; step <= ANIMATION_PREVIEW_PRELOAD_COUNT && step < length; step += 1) {
|
|
const nextIndex = (index + step) % length;
|
|
wanted.add(nextIndex);
|
|
if (!record.preloads.has(nextIndex)) {
|
|
const preload = new Image();
|
|
preload.decoding = "async";
|
|
preload.src = record.schedule[nextIndex].thumbnail_url;
|
|
record.preloads.set(nextIndex, preload);
|
|
}
|
|
}
|
|
for (const existing of record.preloads.keys()) {
|
|
if (!wanted.has(existing)) record.preloads.delete(existing);
|
|
}
|
|
}
|
|
|
|
scheduleTick() {
|
|
if (this.timer !== null) {
|
|
window.clearTimeout(this.timer);
|
|
this.timer = null;
|
|
}
|
|
let nextDelay = Infinity;
|
|
const now = performance.now();
|
|
for (const record of this.records) {
|
|
if (!this.shouldRun(record) || !record.schedule?.length) continue;
|
|
if (record.startedAt === null) record.startedAt = now;
|
|
const selected = animationPreviewFrameAtElapsed(record.schedule, now - record.startedAt);
|
|
if (!selected) continue;
|
|
if (record.currentIndex !== selected.index) {
|
|
record.currentIndex = selected.index;
|
|
record.image.src = selected.frame.thumbnail_url;
|
|
record.onFrame?.({
|
|
frame: selected.frame,
|
|
index: selected.index,
|
|
total: record.schedule.length,
|
|
});
|
|
this.preloadAhead(record, selected.index);
|
|
}
|
|
nextDelay = Math.min(nextDelay, selected.remainingMs);
|
|
}
|
|
if (Number.isFinite(nextDelay)) {
|
|
this.timer = window.setTimeout(() => {
|
|
this.timer = null;
|
|
this.scheduleTick();
|
|
}, Math.max(16, Math.ceil(nextDelay)));
|
|
}
|
|
}
|
|
}
|
|
|
|
let sharedEngine = null;
|
|
|
|
function getSharedEngine() {
|
|
if (!sharedEngine) sharedEngine = new AnimationPreviewEngine();
|
|
return sharedEngine;
|
|
}
|
|
|
|
export function createAnimationPreviewGroup() {
|
|
return getSharedEngine().createGroup();
|
|
}
|
|
|
|
export function setAnimationPreviewMaxConcurrent(value) {
|
|
return getSharedEngine().setMaxConcurrent(value);
|
|
}
|
|
|
|
export function createAnimationThumbnailBox(group, animation) {
|
|
const imageBox = document.createElement("div");
|
|
imageBox.className = "template-image-box";
|
|
if (!animation.thumbnail_url || !animation.frame_count) {
|
|
imageBox.classList.add("template-image-empty");
|
|
imageBox.setAttribute("aria-label", "空动图");
|
|
return imageBox;
|
|
}
|
|
|
|
const image = document.createElement("img");
|
|
image.className = "template-image";
|
|
image.alt = `${animation.name} 动图缩略图`;
|
|
image.width = 64;
|
|
image.height = 64;
|
|
image.draggable = false;
|
|
image.loading = "lazy";
|
|
imageBox.append(image);
|
|
group.register({
|
|
animation,
|
|
image,
|
|
initialUrl: animation.thumbnail_url,
|
|
target: imageBox,
|
|
onLoad: () => {
|
|
image.hidden = false;
|
|
imageBox.classList.remove("template-image-error");
|
|
imageBox.removeAttribute("aria-label");
|
|
},
|
|
onInitialError: () => {
|
|
image.hidden = true;
|
|
imageBox.classList.add("template-image-error");
|
|
imageBox.setAttribute("aria-label", "缩略图加载失败");
|
|
},
|
|
});
|
|
return imageBox;
|
|
}
|