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