moeru-ai/airi · error · Error

Spine ZIP must contain at least one skeleton+atlas pair

Error message

Spine ZIP must contain at least one skeleton+atlas pair

What it means

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.

Source

Thrown at packages/stage-ui-spine/src/utils/spine-zip-loader.ts:249

  const entries: Record<string, string> = {}
  const atlasTexts: Record<string, string> = {}
  const blobUrls: Record<string, string> = {}

  // Pass 1: inventory file paths and read atlas text bodies.
  await Promise.all(Object.keys(archive.files).map(async (name) => {
    const entry = archive.files[name]
    if (entry.dir)
      return

    entries[name] = name

    if (isAtlasPath(name))
      atlasTexts[name] = await entry.async('string')
  }))

  const variants = detectAllSpineLayouts(entries, atlasTexts)
  if (variants.length === 0)
    throw new Error('Spine ZIP must contain at least one skeleton+atlas pair')
  const layout = variants[0].layout

  // Pass 2: materialize assets for ALL variants.
  // NOTICE:
  // Spine's Downloader has a heuristic for rawDataUris: if the value doesn't
  // contain ".", it treats it as a data: URI and calls atob(). In Electron,
  // blob URLs are `blob:null/<uuid>` (no dots), so the Downloader fails.
  // Even with data: URIs, Spine's atob-based decode can corrupt binary data.
  // We store raw decoded data (Uint8Array / string) alongside blob URLs and
  // monkey-patch the Downloader's download methods to serve from memory.
  // Removal condition: Spine ships a Blob-aware or buffer-aware loader.
  const rawData: Record<string, Uint8Array | string> = {}

  // Collect all unique paths across all variants.
  const allTexturePaths = new Set<string>()
  const allSkeletonPaths = new Set<string>()
  const allAtlasPaths = new Set<string>()
  for (const v of variants) {

View on GitHub (pinned to 27111382b4)

Solutions

  1. Keep each model's `.atlas` and `.skel`/`.json` in the same folder with matching basenames.
  2. Re-export from Spine with a consistent layout, or move files in the archive so basenames and folders align.
  3. Call `detectAllSpineLayouts` yourself to see what pairings are possible and adjust.
  4. Verify the skeleton file is present and not corrupted.

Example fix

// before
const assets = await loadSpineZip(blob)  // throws: no pair

// after
// ensure hero.atlas and hero.skel share a folder and basename
// assets/hero.atlas + assets/hero.skel
const assets = await loadSpineZip(blob)
Defensive patterns

Strategy: validation

Validate before calling

const variants = detectAllSpineLayouts(entries, atlasTexts)
if (!variants.length) throw new Error('No usable skeleton+atlas pair in archive')
await loadSpineZip(blob)

Type guard

function hasSkeletonAtlasPair(entries: Record<string, string>, atlasText: Record<string, string>): boolean {
  return detectAllSpineLayouts(entries, atlasText).length > 0
}

Try / catch

try {
  await loadSpineZip(blob)
} catch (e) {
  if (e instanceof Error && e.message.includes('skeleton+atlas pair')) {
    // list entries, re-prompt user
  } else throw e
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of moeru-ai/airi@27111382b4 (2026-08-12). Data as JSON: /api/errors/22a5a95a3692b191. Report an issue: GitHub.