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
- Re-zip the complete Spine export: skeleton (.skel/.json), atlas (.atlas or .atlas.txt), and texture pages (.png)
- Check nested archives — extract inner zips first if present
- Scan entries for isAtlasPath before loading and surface a clear message
- 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
- Zip the complete Spine export (skeleton + atlas + png pages)
- Don't strip 'unused' text files during packaging
- Rename odd extensions to .atlas.txt before zipping
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
- Spine ZIP must contain a .skel or .json skeleton file…
- Spine ZIP must contain at least one skeleton+atlas pair
- Textures not found
- Archive entry not found
- Cannot find file
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)