{"record":{"id":"259a51fa57c5a949","repo":"moeru-ai/airi","slug":"spine-zip-must-contain-a-skel-or-json-skeleton-f","errorCode":null,"errorMessage":"Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas","messagePattern":"Spine ZIP must contain a \\.skel or \\.json skeleton file paired with a \\.atlas","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui-spine/src/utils/spine-zip-loader.ts","lineNumber":106,"sourceCode":"\nfunction dirname(path: string) {\n  const slash = path.lastIndexOf('/')\n  return slash === -1 ? '' : path.slice(0, slash + 1)\n}\n\n/**\n * Inspect a Spine ZIP and resolve the skeleton, atlas, and texture page\n * paths.\n *\n * Heuristics:\n * 1. Find all `.atlas`/`.atlas.txt` files paired with same-basename skeletons.\n * 2. For each pair, walk the atlas to extract texture page filenames.\n * 3. Return the first matched pair as the primary layout.\n */\nexport function detectSpineLayout(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelLayout {\n  const variants = detectAllSpineLayouts(entries, atlasText)\n  if (variants.length === 0)\n    throw new Error('Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas')\n  return variants[0].layout\n}\n\n/**\n * Detects all skeleton+atlas pairs in a ZIP, returning them as named\n * variants. Useful for ZIPs containing multiple outfits/characters in\n * separate folders.\n */\nexport function detectAllSpineLayouts(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelVariant[] {\n  const allFiles = Object.keys(entries)\n  const atlasCandidates = allFiles.filter(isAtlasPath)\n  if (atlasCandidates.length === 0)\n    throw new Error('Spine ZIP must contain a .atlas (or .atlas.txt) file')\n\n  const variants: SpineModelVariant[] = []\n  const usedAtlases = new Set<string>()\n\n  // First pass: match each atlas with a same-basename skeleton.","sourceCodeStart":88,"sourceCodeEnd":124,"githubUrl":"https://github.com/moeru-ai/airi/blob/27111382b4a79a7e983289d6e983a06af185ed0f/packages/stage-ui-spine/src/utils/spine-zip-loader.ts#L88-L124","documentation":"`detectSpineLayout(entries, atlasText)` returns the first matched skeleton+atlas pair by calling `detectAllSpineLayouts`. If no variant is found (`variants.length === 0`) it throws `Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas`. It requires a same-basename skeleton (`.skel` or `.json`) alongside an `.atlas`/`.atlas.txt`.","triggerScenarios":"Calling `detectSpineLayout` with an `entries` map that has an atlas but no matching skeleton, a skeleton but no atlas, or skeleton/atlas with mismatched basenames (`hero.skel` + `character.atlas`). Also when `entries` is empty or only contains textures.","commonSituations":"Spine archive missing the `.atlas` or the `.skel`/`.json`; renamed files breaking the basename pairing; multiple characters in one archive with only one fully paired; a Spine Lite vs Spine Pro export where the skeleton extension differs.","solutions":["Verify the archive has a `<name>.atlas` (or `.atlas.txt`) paired with a `<name>.skel` or `<name>.json` of the same basename in the same folder.","Re-export from Spine with 'Texture packer' producing the `.atlas` alongside the skeleton.","Rename files so skeleton and atlas basenames match exactly.","If multiple characters exist, use `detectAllSpineLayouts` to inspect available pairs."],"exampleFix":"// before\nconst layout = detectSpineLayout(entries, atlasText)  // throws\n\n// after\nconst variants = detectAllSpineLayouts(entries, atlasText)\nif (!variants.length) throw new Error('No skeleton+atlas pair with matching basename found')\nconst layout = variants[0].layout","handlingStrategy":"validation","validationCode":"const variants = detectAllSpineLayouts(entries, atlasText)\nif (!variants.length)\n  throw new Error('No skeleton+atlas pair with matching basename')\nconst layout = variants[0].layout","typeGuard":"function hasSpinePair(entries: Record<string, string>, atlasText: Record<string, string>): boolean {\n  return detectAllSpineLayouts(entries, atlasText).length > 0\n}","tryCatchPattern":"try {\n  detectSpineLayout(entries, atlasText)\n} catch (e) {\n  if (e instanceof Error && e.message.includes('paired with a .atlas')) {\n    // inspect available files and re-prompt\n  } else throw e\n}","preventionTips":["Keep skeleton (.skel/.json) and .atlas in the same folder with matching basenames.","Use detectAllSpineLayouts to inspect multi-character archives.","Re-export from Spine with the texture packer producing the atlas."],"tags":["spine","model-loading","archive","input-validation"],"backgroundTag":null,"analyzedSha":"27111382b4a79a7e983289d6e983a06af185ed0f","analyzedAt":"2026-08-12T18:33:34.132Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}