moeru-ai/airi · error · Error

Spine ZIP must contain a .atlas (or .atlas.txt) file

Error message

Spine ZIP must contain a .atlas (or .atlas.txt) file

What it means

Thrown by detectAllSpineLayouts when no file path in the archive matches isAtlasPath — i.e. the ZIP contains no .atlas or .atlas.txt entry at all. This is the earliest structural check: without an atlas there is nothing to pair with skeletons or to derive texture pages from, so Spine loading cannot proceed.

Solutions

  1. Re-zip the complete Spine export: skeleton (.skel/.json), atlas (.atlas or .atlas.txt), and texture pages (.png)
  2. Check nested archives — extract inner zips first if present
  3. Scan entries for isAtlasPath before loading and surface a clear message
  4. If the atlas genuinely has an unusual extension, rename it to .atlas.txt (contents are text) before zipping

Example fix

// before
const assets = await loadSpineModelFromZip(file) // throws '.atlas (or .atlas.txt) file'

// after
const names = Object.keys(zip.files)
if (!names.some(n => /\.atlas(\.txt)?$/i.test(n))) {
  throw new Error('No .atlas file found. Include the atlas exported by Spine (hero.atlas) together with the skeleton and textures.')
}
Defensive patterns

Strategy: validation

Validate before calling

const hasAtlas = Object.keys(entries).some(n => /\.atlas(\.txt)?$/i.test(n))
if (!hasAtlas) throw new Error('Spine export is missing its .atlas file')

Type guard

const isAtlasPath = (p: string): boolean => /\.atlas(\.txt)?$/i.test(p)

Try / catch

catch (e) { if (e.message.includes('.atlas (or .atlas.txt) file')) { showUserError('Include the atlas from the Spine export'); return } throw e }

Prevention

When it happens

Trigger: Uploading a ZIP with only .skel/.json + .png (atlas omitted); atlas renamed to .atlasTXT or packed into a nested inner zip; uploading a spine export's 'skeletons-only' folder; packaging that swallowed the atlas into a different archive.

Common situations: Users selecting the wrong folder of a Spine export; pipeline that filters 'unnecessary' text files and drops the .atlas; .atlas.txt double-extension renamed by OS tooling to .txt; asset pipelines zipping images and skeletons separately for size.

Related errors


AI-assisted analysis of moeru-ai/airi@677329427f (2026-08-18). Data as JSON: /api/errors/d85b7d415d533f3e. Report an issue: GitHub.

Appendix: source

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

 * 3. Return the first matched pair as the primary layout.
 */
export function detectSpineLayout(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelLayout {
  const variants = detectAllSpineLayouts(entries, atlasText)
  if (variants.length === 0)
    throw new Error('Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas')
  return variants[0].layout
}

/**
 * Detects all skeleton+atlas pairs in a ZIP, returning them as named
 * variants. Useful for ZIPs containing multiple outfits/characters in
 * separate folders.
 */
export function detectAllSpineLayouts(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelVariant[] {
  const allFiles = Object.keys(entries)
  const atlasCandidates = allFiles.filter(isAtlasPath)
  if (atlasCandidates.length === 0)
    throw new Error('Spine ZIP must contain a .atlas (or .atlas.txt) file')

  const variants: SpineModelVariant[] = []
  const usedAtlases = new Set<string>()

  // First pass: match each atlas with a same-basename skeleton.
  for (const candidate of atlasCandidates) {
    const baseName = stripExt(stripExt(basename(candidate)))
    const dir = dirname(candidate)

    const binaryPath = `${dir}${baseName}${SKELETON_BINARY_EXT}`
    const jsonPath = `${dir}${baseName}${SKELETON_JSON_EXT}`

    let skeletonPath: string | undefined
    let skeletonFormat: SpineModelLayout['skeletonFormat'] = 'binary'

    if (entries[binaryPath] !== undefined) {
      skeletonPath = binaryPath
      skeletonFormat = 'binary'

View on GitHub (pinned to 677329427f)