初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,466 @@
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;
}