{"record":{"id":"22a5a95a3692b191","repo":"moeru-ai/airi","slug":"spine-zip-must-contain-at-least-one-skeleton-atlas","errorCode":null,"errorMessage":"Spine ZIP must contain at least one skeleton+atlas pair","messagePattern":"Spine ZIP must contain at least one skeleton\\+atlas pair","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui-spine/src/utils/spine-zip-loader.ts","lineNumber":249,"sourceCode":"  const entries: Record<string, string> = {}\n  const atlasTexts: Record<string, string> = {}\n  const blobUrls: Record<string, string> = {}\n\n  // Pass 1: inventory file paths and read atlas text bodies.\n  await Promise.all(Object.keys(archive.files).map(async (name) => {\n    const entry = archive.files[name]\n    if (entry.dir)\n      return\n\n    entries[name] = name\n\n    if (isAtlasPath(name))\n      atlasTexts[name] = await entry.async('string')\n  }))\n\n  const variants = detectAllSpineLayouts(entries, atlasTexts)\n  if (variants.length === 0)\n    throw new Error('Spine ZIP must contain at least one skeleton+atlas pair')\n  const layout = variants[0].layout\n\n  // Pass 2: materialize assets for ALL variants.\n  // NOTICE:\n  // Spine's Downloader has a heuristic for rawDataUris: if the value doesn't\n  // contain \".\", it treats it as a data: URI and calls atob(). In Electron,\n  // blob URLs are `blob:null/<uuid>` (no dots), so the Downloader fails.\n  // Even with data: URIs, Spine's atob-based decode can corrupt binary data.\n  // We store raw decoded data (Uint8Array / string) alongside blob URLs and\n  // monkey-patch the Downloader's download methods to serve from memory.\n  // Removal condition: Spine ships a Blob-aware or buffer-aware loader.\n  const rawData: Record<string, Uint8Array | string> = {}\n\n  // Collect all unique paths across all variants.\n  const allTexturePaths = new Set<string>()\n  const allSkeletonPaths = new Set<string>()\n  const allAtlasPaths = new Set<string>()\n  for (const v of variants) {","sourceCodeStart":231,"sourceCodeEnd":267,"githubUrl":"https://github.com/moeru-ai/airi/blob/27111382b4a79a7e983289d6e983a06af185ed0f/packages/stage-ui-spine/src/utils/spine-zip-loader.ts#L231-L267","documentation":"After loading the ZIP and reading all atlas texts, `loadSpineZip` runs `detectAllSpineLayouts`. If it returns zero variants it throws `Spine ZIP must contain at least one skeleton+atlas pair`. This is the loader-level guard (vs. `detectSpineLayout` which throws on the first-pair path) — it means atlas files may exist but none could be paired with a same-basename skeleton.","triggerScenarios":"An archive that has `.atlas` files but no `.skel`/`.json` with a matching basename in the same folder; atlas and skeleton in different directories (`assets/hero.atlas` + `skeletons/hero.skel`); atlas with one basename and skeleton with another; only textures present.","commonSituations":"Folder reorganization that separated atlas from skeleton; renamed skeleton or atlas; Spine export that placed assets and skeleton in different subfolders; partial download missing the skeleton.","solutions":["Keep each model's `.atlas` and `.skel`/`.json` in the same folder with matching basenames.","Re-export from Spine with a consistent layout, or move files in the archive so basenames and folders align.","Call `detectAllSpineLayouts` yourself to see what pairings are possible and adjust.","Verify the skeleton file is present and not corrupted."],"exampleFix":"// before\nconst assets = await loadSpineZip(blob)  // throws: no pair\n\n// after\n// ensure hero.atlas and hero.skel share a folder and basename\n// assets/hero.atlas + assets/hero.skel\nconst assets = await loadSpineZip(blob)","handlingStrategy":"validation","validationCode":"const variants = detectAllSpineLayouts(entries, atlasTexts)\nif (!variants.length) throw new Error('No usable skeleton+atlas pair in archive')\nawait loadSpineZip(blob)","typeGuard":"function hasSkeletonAtlasPair(entries: Record<string, string>, atlasText: Record<string, string>): boolean {\n  return detectAllSpineLayouts(entries, atlasText).length > 0\n}","tryCatchPattern":"try {\n  await loadSpineZip(blob)\n} catch (e) {\n  if (e instanceof Error && e.message.includes('skeleton+atlas pair')) {\n    // list entries, re-prompt user\n  } else throw e\n}","preventionTips":["Keep each model's atlas and skeleton in the same folder with matching basenames.","Avoid splitting assets and skeletons across directories.","Re-export from Spine with a consistent layout."],"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"}