Files
Genarrative/tools/spine-json-export-validator/src/main.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

731 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './style.css';
import type {
AttachmentLoader,
BoundingBoxAttachment,
ClippingAttachment,
MeshAttachment,
PathAttachment,
PointAttachment,
Sequence,
Skin,
} from '@esotericsoftware/spine-webgl';
import {
AnimationState,
AnimationStateData,
GLTexture,
ManagedWebGLRenderingContext,
Physics,
RegionAttachment,
ResizeMode,
SceneRenderer,
Skeleton,
SkeletonJson,
TextureRegion,
} from '@esotericsoftware/spine-webgl';
import type { JSZipObject } from 'jszip';
import JSZip from 'jszip';
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg'];
const SKELETON_FILE_NAME = 'skeleton.json';
type Tone = 'info' | 'success' | 'error';
type JsonRecord = Record<string, unknown>;
type SkeletonCandidate = {
path: string;
baseDir: string;
json: JsonRecord;
};
type RegionExportInfo = {
paths: string[];
attachmentCount: number;
unsupportedTypes: string[];
};
type LoadedFrame = {
zipPath: string;
image: HTMLImageElement | ImageBitmap;
width: number;
height: number;
};
type ActiveSkeleton = {
skeleton: Skeleton;
state: AnimationState;
textures: GLTexture[];
frames: LoadedFrame[];
};
const zipInput = queryElement<HTMLInputElement>('#zipInput');
const dropZone = queryElement<HTMLElement>('#dropZone');
const previewCanvas = queryElement<HTMLCanvasElement>('#previewCanvas');
const emptyState = queryElement<HTMLElement>('#emptyState');
const statusBox = queryElement<HTMLElement>('#statusBox');
const skeletonSelect = queryElement<HTMLSelectElement>('#skeletonSelect');
const summaryList = queryElement<HTMLDListElement>('#summaryList');
const logList = queryElement<HTMLUListElement>('#logList');
let activeZip: JSZip | null = null;
let activeCandidates: SkeletonCandidate[] = [];
let activePreview: SpinePreview | null = null;
function queryElement<T extends Element>(selector: string) {
const element = document.querySelector<T>(selector);
if (!element) {
throw new Error(`Missing element: ${selector}`);
}
return element;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function normalizeZipPath(path: string) {
const parts: string[] = [];
for (const part of path.replace(/\\/gu, '/').split('/')) {
if (!part || part === '.') {
continue;
}
if (part === '..') {
parts.pop();
continue;
}
parts.push(part);
}
return parts.join('/');
}
function joinZipPath(...parts: string[]) {
return normalizeZipPath(parts.join('/'));
}
function getBaseDir(path: string) {
const normalized = normalizeZipPath(path);
const slashIndex = normalized.lastIndexOf('/');
return slashIndex >= 0 ? normalized.slice(0, slashIndex) : '';
}
function stripExtension(path: string) {
return path.replace(/\.[^.\\/]+$/u, '');
}
function hasImageExtension(path: string) {
const extension = path.split('.').pop()?.toLowerCase();
return extension ? IMAGE_EXTENSIONS.includes(extension) : false;
}
function toFrameKey(path: string) {
return stripExtension(normalizeZipPath(path)).toLowerCase();
}
function setStatus(tone: Tone, message: string) {
statusBox.dataset.tone = tone;
statusBox.textContent = message;
}
function clearLogs() {
logList.replaceChildren();
}
function addLog(message: string, tone: Tone = 'info') {
const item = document.createElement('li');
item.dataset.tone = tone;
item.textContent = message;
logList.prepend(item);
}
function renderSummary(rows: Array<[string, string]>) {
const fragment = document.createDocumentFragment();
for (const [label, value] of rows) {
const term = document.createElement('dt');
term.textContent = label;
const description = document.createElement('dd');
description.textContent = value;
fragment.append(term, description);
}
summaryList.replaceChildren(fragment);
}
function getSkeletonImagesPath(json: JsonRecord) {
const skeleton = isRecord(json.skeleton) ? json.skeleton : null;
return typeof skeleton?.images === 'string' ? skeleton.images : './';
}
function collectRegionExportInfo(json: JsonRecord): RegionExportInfo {
const paths = new Set<string>();
const unsupportedTypes = new Set<string>();
let attachmentCount = 0;
const skins = Array.isArray(json.skins) ? json.skins : [];
for (const skin of skins) {
if (!isRecord(skin) || !isRecord(skin.attachments)) {
continue;
}
for (const slotAttachments of Object.values(skin.attachments)) {
if (!isRecord(slotAttachments)) {
continue;
}
for (const [attachmentName, attachmentValue] of Object.entries(
slotAttachments,
)) {
if (!isRecord(attachmentValue)) {
continue;
}
attachmentCount += 1;
const attachmentType =
typeof attachmentValue.type === 'string'
? attachmentValue.type
: 'region';
if (attachmentType !== 'region') {
unsupportedTypes.add(attachmentType);
continue;
}
const path =
typeof attachmentValue.path === 'string'
? attachmentValue.path
: attachmentName;
paths.add(path);
}
}
}
return {
paths: [...paths],
attachmentCount,
unsupportedTypes: [...unsupportedTypes],
};
}
function collectAnimationNames(json: JsonRecord) {
return isRecord(json.animations) ? Object.keys(json.animations) : [];
}
function resolveImageEntry({
zip,
imageRoot,
attachmentPath,
}: {
zip: JSZip;
imageRoot: string;
attachmentPath: string;
}) {
const normalizedRoot = normalizeZipPath(imageRoot);
const normalizedAttachment = normalizeZipPath(attachmentPath);
const directCandidates = hasImageExtension(normalizedAttachment)
? [joinZipPath(normalizedRoot, normalizedAttachment)]
: IMAGE_EXTENSIONS.map((extension) =>
joinZipPath(normalizedRoot, `${normalizedAttachment}.${extension}`),
);
const filesByLowerPath = new Map<string, JSZipObject>();
zip.forEach((path, entry) => {
if (!entry.dir) {
filesByLowerPath.set(normalizeZipPath(path).toLowerCase(), entry);
}
});
for (const candidate of directCandidates) {
const entry = filesByLowerPath.get(candidate.toLowerCase());
if (entry) {
return entry;
}
}
const rootPrefix = normalizedRoot ? `${normalizedRoot}/` : '';
const expectedKey = toFrameKey(normalizedAttachment);
let fallback: JSZipObject | null = null;
zip.forEach((path, entry) => {
if (fallback || entry.dir) {
return;
}
const normalizedPath = normalizeZipPath(path);
if (!normalizedPath.startsWith(rootPrefix)) {
return;
}
const relativePath = normalizedPath.slice(rootPrefix.length);
if (toFrameKey(relativePath) === expectedKey) {
fallback = entry;
}
});
return fallback;
}
async function loadImageFromZipEntry(entry: JSZipObject): Promise<LoadedFrame> {
const blob = await entry.async('blob');
if (typeof createImageBitmap === 'function') {
try {
const bitmap = await createImageBitmap(blob);
return {
zipPath: normalizeZipPath(entry.name),
image: bitmap,
width: bitmap.width,
height: bitmap.height,
};
} catch {
// Some browsers reject SVG or unusual PNG chunks here; HTMLImageElement is the safer fallback.
}
}
const image = new Image();
const objectUrl = URL.createObjectURL(blob);
try {
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error(`图片解码失败:${entry.name}`));
image.src = objectUrl;
});
} finally {
URL.revokeObjectURL(objectUrl);
}
return {
zipPath: normalizeZipPath(entry.name),
image,
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
};
}
async function loadFramesForCandidate(
zip: JSZip,
candidate: SkeletonCandidate,
) {
const exportInfo = collectRegionExportInfo(candidate.json);
const imageRoot = joinZipPath(
candidate.baseDir,
getSkeletonImagesPath(candidate.json),
);
const frames = new Map<string, LoadedFrame>();
const missingFrames: string[] = [];
for (const attachmentPath of exportInfo.paths) {
const frameKey = toFrameKey(attachmentPath);
if (frames.has(frameKey)) {
continue;
}
const entry = resolveImageEntry({ zip, imageRoot, attachmentPath });
if (!entry) {
missingFrames.push(attachmentPath);
continue;
}
frames.set(frameKey, await loadImageFromZipEntry(entry));
}
return { exportInfo, imageRoot, frames, missingFrames };
}
async function discoverSkeletonCandidates(zip: JSZip) {
const candidates: SkeletonCandidate[] = [];
const invalidJsonPaths: string[] = [];
const entries: JSZipObject[] = [];
zip.forEach((path, entry) => {
if (entry.dir) {
return;
}
const normalizedPath = normalizeZipPath(path);
if (normalizedPath.split('/').pop() === SKELETON_FILE_NAME) {
entries.push(entry);
}
});
for (const entry of entries) {
try {
const text = await entry.async('text');
const json = JSON.parse(text) as unknown;
if (!isRecord(json)) {
invalidJsonPaths.push(entry.name);
continue;
}
candidates.push({
path: normalizeZipPath(entry.name),
baseDir: getBaseDir(entry.name),
json,
});
} catch {
invalidJsonPaths.push(entry.name);
}
}
return { candidates, invalidJsonPaths };
}
function populateSkeletonSelect(candidates: SkeletonCandidate[]) {
const fragment = document.createDocumentFragment();
for (const [index, candidate] of candidates.entries()) {
const option = document.createElement('option');
option.value = String(index);
option.textContent = candidate.path;
fragment.append(option);
}
skeletonSelect.replaceChildren(fragment);
skeletonSelect.disabled = candidates.length <= 1;
}
function closeLoadedFrames(frames: LoadedFrame[]) {
for (const frame of frames) {
if ('close' in frame.image && typeof frame.image.close === 'function') {
frame.image.close();
}
}
}
class LooseFrameAttachmentLoader implements AttachmentLoader {
readonly textures: GLTexture[] = [];
constructor(
private readonly context: ManagedWebGLRenderingContext,
private readonly frames: Map<string, LoadedFrame>,
) {}
newRegionAttachment(
skin: Skin,
placeholder: string,
name: string,
path: string,
sequence: Sequence,
) {
for (let index = 0; index < sequence.regions.length; index += 1) {
const sequencePath = sequence.hasPathSuffix()
? sequence.getPath(path, index)
: path;
sequence.regions[index] = this.createRegion(sequencePath);
}
return new RegionAttachment(name, sequence);
}
newMeshAttachment(
skin: Skin,
placeholder: string,
name: string,
_path: string,
_sequence: Sequence,
): MeshAttachment {
throw new Error(`暂不支持 mesh attachment${name}`);
}
newBoundingBoxAttachment(
skin: Skin,
placeholder: string,
name: string,
): BoundingBoxAttachment {
throw new Error(`暂不支持 boundingbox attachment${name}`);
}
newPathAttachment(
skin: Skin,
placeholder: string,
name: string,
): PathAttachment {
throw new Error(`暂不支持 path attachment${name}`);
}
newPointAttachment(
skin: Skin,
placeholder: string,
name: string,
): PointAttachment {
throw new Error(`暂不支持 point attachment${name}`);
}
newClippingAttachment(
skin: Skin,
placeholder: string,
name: string,
): ClippingAttachment {
throw new Error(`暂不支持 clipping attachment${name}`);
}
private createRegion(path: string) {
const frame = this.frames.get(toFrameKey(path));
if (!frame) {
throw new Error(`找不到贴图帧:${path}`);
}
const texture = new GLTexture(this.context, frame.image, false);
this.textures.push(texture);
const region = new TextureRegion();
region.texture = texture;
region.u = 0;
region.v = 0;
region.u2 = 1;
region.v2 = 1;
region.width = frame.width;
region.height = frame.height;
region.originalWidth = frame.width;
region.originalHeight = frame.height;
region.offsetX = 0;
region.offsetY = 0;
region.degrees = 0;
return region;
}
}
class SpinePreview {
private readonly context: ManagedWebGLRenderingContext;
private readonly renderer: SceneRenderer;
private activeSkeleton: ActiveSkeleton | null = null;
private frameHandle: number | null = null;
private lastFrameMs = performance.now();
constructor(private readonly canvas: HTMLCanvasElement) {
this.context = new ManagedWebGLRenderingContext(canvas, {
alpha: true,
premultipliedAlpha: false,
});
this.renderer = new SceneRenderer(canvas, this.context);
this.startLoop();
}
load({
json,
frames,
}: {
json: JsonRecord;
frames: Map<string, LoadedFrame>;
}) {
this.clearActiveSkeleton();
const loader = new LooseFrameAttachmentLoader(this.context, frames);
const skeletonData = new SkeletonJson(loader).readSkeletonData(json);
const skeleton = new Skeleton(skeletonData);
if (skeletonData.defaultSkin) {
skeleton.setSkin(skeletonData.defaultSkin);
}
skeleton.setupPose();
const state = new AnimationState(new AnimationStateData(skeletonData));
const firstAnimation = skeletonData.animations[0];
if (firstAnimation) {
state.setAnimation(0, firstAnimation, true);
state.apply(skeleton);
}
skeleton.updateWorldTransform(Physics.none);
this.activeSkeleton = {
skeleton,
state,
textures: loader.textures,
frames: [...frames.values()],
};
this.fitCamera();
}
dispose() {
this.clearActiveSkeleton();
if (this.frameHandle !== null) {
cancelAnimationFrame(this.frameHandle);
this.frameHandle = null;
}
this.renderer.dispose();
this.context.dispose();
}
private clearActiveSkeleton() {
if (!this.activeSkeleton) {
return;
}
for (const texture of this.activeSkeleton.textures) {
texture.dispose();
}
closeLoadedFrames(this.activeSkeleton.frames);
this.activeSkeleton = null;
}
private startLoop() {
const tick = (timestamp: number) => {
const delta = Math.min((timestamp - this.lastFrameMs) / 1000, 0.064);
this.lastFrameMs = timestamp;
this.render(delta);
this.frameHandle = requestAnimationFrame(tick);
};
this.frameHandle = requestAnimationFrame(tick);
}
private render(delta: number) {
const gl = this.context.gl;
this.fitCamera();
gl.clearColor(0.04, 0.05, 0.07, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
const activeSkeleton = this.activeSkeleton;
if (!activeSkeleton) {
return;
}
activeSkeleton.state.update(delta);
activeSkeleton.state.apply(activeSkeleton.skeleton);
activeSkeleton.skeleton.update(delta);
activeSkeleton.skeleton.updateWorldTransform(Physics.none);
this.renderer.begin();
this.renderer.drawSkeleton(activeSkeleton.skeleton);
this.renderer.end();
}
private fitCamera() {
const activeSkeleton = this.activeSkeleton;
const cssWidth = Math.max(1, this.canvas.clientWidth || this.canvas.width);
const cssHeight = Math.max(
1,
this.canvas.clientHeight || this.canvas.height,
);
if (!activeSkeleton) {
this.renderer.camera.position.set(0, 0, 0);
this.renderer.camera.setViewport(cssWidth, cssHeight);
this.renderer.resize(ResizeMode.Stretch);
return;
}
const bounds = activeSkeleton.skeleton.getBoundsRect();
const fallbackWidth = activeSkeleton.skeleton.data.width || cssWidth;
const fallbackHeight = activeSkeleton.skeleton.data.height || cssHeight;
const boundsWidth = bounds.width > 0 ? bounds.width : fallbackWidth;
const boundsHeight = bounds.height > 0 ? bounds.height : fallbackHeight;
const centerX = bounds.x + boundsWidth / 2;
const centerY = bounds.y + boundsHeight / 2;
let viewportWidth = Math.max(1, boundsWidth * 1.18);
let viewportHeight = Math.max(1, boundsHeight * 1.18);
const canvasRatio = cssWidth / cssHeight;
const viewportRatio = viewportWidth / viewportHeight;
if (viewportRatio > canvasRatio) {
viewportHeight = viewportWidth / canvasRatio;
} else {
viewportWidth = viewportHeight * canvasRatio;
}
this.renderer.camera.position.set(centerX, centerY, 0);
this.renderer.camera.setViewport(viewportWidth, viewportHeight);
this.renderer.resize(ResizeMode.Stretch);
}
}
async function loadCandidate(index: number) {
if (!activeZip) {
return;
}
const candidate = activeCandidates[index];
if (!candidate) {
return;
}
try {
setStatus('info', '正在加载 Spine JSON');
const { exportInfo, imageRoot, frames, missingFrames } =
await loadFramesForCandidate(activeZip, candidate);
const animationNames = collectAnimationNames(candidate.json);
if (missingFrames.length) {
throw new Error(`缺少贴图帧:${missingFrames.join(', ')}`);
}
if (!exportInfo.paths.length) {
throw new Error('未找到 region attachment');
}
if (exportInfo.unsupportedTypes.length) {
addLog(
`发现暂不支持的 attachment${exportInfo.unsupportedTypes.join(', ')}`,
'error',
);
}
if (!activePreview) {
activePreview = new SpinePreview(previewCanvas);
}
activePreview.load({ json: candidate.json, frames });
emptyState.hidden = true;
renderSummary([
['JSON', candidate.path],
['贴图目录', imageRoot || '.'],
['动画', animationNames.join(', ') || '-'],
['Region', String(exportInfo.attachmentCount)],
['帧数', String(frames.size)],
]);
setStatus('success', '导出包可被 Spine JS runtime 解析并渲染');
addLog(`已渲染:${candidate.path}`, 'success');
} catch (error) {
setStatus('error', error instanceof Error ? error.message : '加载失败');
addLog(error instanceof Error ? error.message : '加载失败', 'error');
emptyState.hidden = false;
}
}
async function loadZipFile(file: File) {
clearLogs();
renderSummary([]);
setStatus('info', '正在读取 ZIP');
emptyState.hidden = false;
emptyState.textContent = '正在读取 ZIP';
try {
const zip = await JSZip.loadAsync(file);
const { candidates, invalidJsonPaths } =
await discoverSkeletonCandidates(zip);
activeZip = zip;
activeCandidates = candidates;
populateSkeletonSelect(candidates);
for (const invalidPath of invalidJsonPaths) {
addLog(`JSON 解析失败:${invalidPath}`, 'error');
}
if (!candidates.length) {
throw new Error('ZIP 中没有 skeleton.json');
}
setStatus('info', `找到 ${candidates.length} 个 skeleton.json`);
addLog(`已读取:${file.name}`);
skeletonSelect.value = '0';
await loadCandidate(0);
} catch (error) {
activeZip = null;
activeCandidates = [];
populateSkeletonSelect([]);
setStatus('error', error instanceof Error ? error.message : 'ZIP 读取失败');
emptyState.textContent = '拖入 ZIP 开始验证';
addLog(error instanceof Error ? error.message : 'ZIP 读取失败', 'error');
}
}
zipInput.addEventListener('change', () => {
const file = zipInput.files?.[0];
if (file) {
void loadZipFile(file);
}
});
skeletonSelect.addEventListener('change', () => {
void loadCandidate(Number(skeletonSelect.value));
});
for (const eventName of ['dragenter', 'dragover']) {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.dataset.dragging = 'true';
});
}
for (const eventName of ['dragleave', 'drop']) {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.dataset.dragging = 'false';
});
}
dropZone.addEventListener('drop', (event) => {
const file = event.dataTransfer?.files[0];
if (file) {
void loadZipFile(file);
}
});
window.addEventListener('beforeunload', () => {
activePreview?.dispose();
});