sprout-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sprout/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sprout — toolchain determinista de assets 2D procedurales para Expo + react-native-skia."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,78 @@
1
+ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
2
+ Upstream-Name: DejaVu fonts
3
+ Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
4
+ see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
5
+ Source: https://dejavu-fonts.github.io/
6
+
7
+ Files: *
8
+ Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
9
+ Bitstream Vera is a trademark of Bitstream, Inc.
10
+ DejaVu changes are in public domain.
11
+ License: bitstream-vera
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of the fonts accompanying this license ("Fonts") and associated
14
+ documentation files (the "Font Software"), to reproduce and distribute the
15
+ Font Software, including without limitation the rights to use, copy, merge,
16
+ publish, distribute, and/or sell copies of the Font Software, and to permit
17
+ persons to whom the Font Software is furnished to do so, subject to the
18
+ following conditions:
19
+ .
20
+ The above copyright and trademark notices and this permission notice shall
21
+ be included in all copies of one or more of the Font Software typefaces.
22
+ .
23
+ The Font Software may be modified, altered, or added to, and in particular
24
+ the designs of glyphs or characters in the Fonts may be modified and
25
+ additional glyphs or characters may be added to the Fonts, only if the fonts
26
+ are renamed to names not containing either the words "Bitstream" or the word
27
+ "Vera".
28
+ .
29
+ This License becomes null and void to the extent applicable to Fonts or Font
30
+ Software that has been modified and is distributed under the "Bitstream
31
+ Vera" names.
32
+ .
33
+ The Font Software may be sold as part of a larger software package but no
34
+ copy of one or more of the Font Software typefaces may be sold by itself.
35
+ .
36
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
37
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
38
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
39
+ TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
40
+ FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
41
+ ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
42
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
43
+ THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
44
+ FONT SOFTWARE.
45
+ .
46
+ Except as contained in this notice, the names of Gnome, the Gnome
47
+ Foundation, and Bitstream Inc., shall not be used in advertising or
48
+ otherwise to promote the sale, use or other dealings in this Font Software
49
+ without prior written authorization from the Gnome Foundation or Bitstream
50
+ Inc., respectively. For further information, contact: fonts at gnome dot
51
+ org.
52
+
53
+ Files: debian/*
54
+ Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
55
+ (C) 2006-2011 Davide Viti <zinosat@tiscali.it>
56
+ (C) 2011-2013 Christian Perrier <bubulle@debian.org>
57
+ (C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
58
+ License: GPL-2+
59
+ This program is free software; you can redistribute it
60
+ and/or modify it under the terms of the GNU General Public
61
+ License as published by the Free Software Foundation; either
62
+ version 2 of the License, or (at your option) any later
63
+ version.
64
+ .
65
+ This program is distributed in the hope that it will be
66
+ useful, but WITHOUT ANY WARRANTY; without even the implied
67
+ warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
68
+ PURPOSE. See the GNU General Public License for more
69
+ details.
70
+ .
71
+ You should have received a copy of the GNU General Public
72
+ License along with this package; if not, write to the Free
73
+ Software Foundation, Inc., 51 Franklin St, Fifth Floor,
74
+ Boston, MA 02110-1301 USA
75
+ .
76
+ On Debian systems, the full text of the GNU General Public
77
+ License version 2 can be found in the file
78
+ /usr/share/common-licenses/GPL-2'.
sprout/autotile.py ADDED
@@ -0,0 +1,57 @@
1
+ """Autotiling 16 / 47 — reducción canónica de máscaras de vecindad 8-bit.
2
+
3
+ Bits (convención canónica sprout, ver INVESTIGACION.md §5.3)::
4
+
5
+ N=1, NE=2, E=4, SE=8, S=16, SW=32, W=64, NW=128
6
+
7
+ El bit diagonal solo cuenta si **ambos** cardinales adyacentes están presentes:
8
+ así 2**8 = 256 vecindades colapsan a 47. Un "corner bit" describe el cuadrante
9
+ donde se juntan sus dos lados; si falta un lado, ese cuadrante ya es borde
10
+ exterior y la diagonal no aporta una silueta distinta.
11
+
12
+ Referencia verificada contra Godot 4.7 (Blobsmith Autotile Wirer, MIT):
13
+ https://github.com/leobaray/blobsmith-autotile-wirer/blob/master/docs/why-47-tiles-not-256.md
14
+ """
15
+ from __future__ import annotations
16
+
17
+ N, NE, E, SE, S, SW, W, NW = 1, 2, 4, 8, 16, 32, 64, 128
18
+
19
+ SIDES = N | E | S | W # 0b01010101 == 85
20
+ CORNERS = NE | SE | SW | NW
21
+
22
+ BITMASK = "N1,E4,S16,W64,NE2,SE8,SW32,NW128"
23
+
24
+
25
+ def canonical_47(mask: int) -> int:
26
+ """Máscara canónica del sistema 8-bit (47 clases)."""
27
+ m = mask & SIDES
28
+ if (mask & NE) and (mask & N) and (mask & E):
29
+ m |= NE
30
+ if (mask & SE) and (mask & S) and (mask & E):
31
+ m |= SE
32
+ if (mask & SW) and (mask & S) and (mask & W):
33
+ m |= SW
34
+ if (mask & NW) and (mask & N) and (mask & W):
35
+ m |= NW
36
+ return m
37
+
38
+
39
+ def canonical_16(mask: int) -> int:
40
+ """Máscara canónica del sistema cardinal (16 clases, Match Sides)."""
41
+ return mask & SIDES
42
+
43
+
44
+ def canonical(mask: int, size: int) -> int:
45
+ if size == 16:
46
+ return canonical_16(mask)
47
+ if size == 47:
48
+ return canonical_47(mask)
49
+ raise ValueError(f"autotile no soportado: {size!r} (esperado 16 | 47)")
50
+
51
+
52
+ def masks(size: int) -> list[int]:
53
+ """Máscaras canónicas en orden ascendente = orden de la hoja (cols=8)."""
54
+ fn = {16: canonical_16, 47: canonical_47}.get(size)
55
+ if fn is None:
56
+ raise ValueError(f"autotile no soportado: {size!r} (esperado 16 | 47)")
57
+ return [m for m in range(256) if fn(m) == m]
sprout/cli.py ADDED
@@ -0,0 +1,518 @@
1
+ """CLI `sprout`: generación procedural determinista de assets 2D para Expo.
2
+
3
+ Uso:
4
+ sprout generate specs/demo.json --out ../demo/assets/procgen
5
+ sprout generate specs/demo.json --png-mode png8 --texturepacker --mipmaps
6
+ sprout batch specs/ --out ../demo/assets/procgen
7
+ sprout watch specs/ --out ../demo/assets/procgen
8
+ sprout info specs/demo.json [--json]
9
+ sprout lint specs/demo.json [--json]
10
+ sprout diff <a> <b> [--json]
11
+ sprout validate specs/demo.json
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import json
17
+ import time
18
+ import zlib
19
+ from pathlib import Path
20
+
21
+ import typer
22
+
23
+ from . import __version__
24
+ from .exporter import (
25
+ apply_png_mode,
26
+ build_autotile_map,
27
+ build_font_map,
28
+ build_manifest,
29
+ build_shader,
30
+ build_sheet,
31
+ build_texturepacker,
32
+ compute_mipmap_meta,
33
+ emit_index_ts,
34
+ render_items,
35
+ shader_filename,
36
+ texturepacker_filename,
37
+ write_manifest,
38
+ write_mipmap_files,
39
+ write_png,
40
+ write_texturepacker,
41
+ )
42
+ from .generators.base import FrameData
43
+ from .spec import Spec, SpecError, load_spec
44
+
45
+ app = typer.Typer(add_completion=False, no_args_is_help=True,
46
+ help="sprout — assets 2D procedurales deterministas para Expo + react-native-skia.")
47
+
48
+
49
+ PNG_MODES = ("rgba", "png8", "png24")
50
+
51
+
52
+ def _generate(spec_path: Path, out_dir: Path | None, seed: int | None,
53
+ skip_existing: bool, png_mode: str = "rgba",
54
+ texturepacker: bool = False, mipmaps: bool = False,
55
+ mip_levels: int = 3) -> dict:
56
+ spec = load_spec(spec_path)
57
+ if seed is not None:
58
+ spec.seed = seed
59
+
60
+ frames = render_items(spec)
61
+ sheet, records = build_sheet(spec, frames)
62
+ out = out_dir if out_dir is not None else spec_path.parent
63
+ atlas_name = spec.filename
64
+ atlas_path = out / atlas_name
65
+ manifest_path = out / "manifest.json"
66
+ index_path = out / "index.ts"
67
+ tp_path = out / texturepacker_filename(spec) if texturepacker else None
68
+
69
+ autotile_map = build_autotile_map(spec, frames)
70
+ font_map = build_font_map(spec, frames)
71
+ shader_source, shader_block = build_shader(spec)
72
+ shader_path = out / shader_filename(spec) if shader_block else None
73
+ mip_meta = compute_mipmap_meta(sheet, spec, mip_levels) if mipmaps else []
74
+ manifest = build_manifest(spec, records, atlas_name, sheet, str(spec_path),
75
+ autotile_map, shader_block, font_map,
76
+ {"levels": mip_meta} if mip_meta else None)
77
+
78
+ if skip_existing and atlas_path.is_file() and manifest_path.is_file() and index_path.is_file():
79
+ missing_extra = (
80
+ (shader_block and not (shader_path and shader_path.is_file()))
81
+ or (texturepacker and not tp_path.is_file())
82
+ or (mipmaps and not all((out / lvl["file"]).is_file() for lvl in mip_meta))
83
+ )
84
+ if missing_extra:
85
+ pass # falta un artefacto opcional -> regenerar
86
+ else:
87
+ current = zlib.crc32(atlas_path.read_bytes()) & 0xFFFFFFFF
88
+ probe = io.BytesIO()
89
+ apply_png_mode(sheet, png_mode).save(probe, format="PNG")
90
+ fresh = zlib.crc32(probe.getvalue()) & 0xFFFFFFFF
91
+ same_manifest = manifest_path.read_bytes() == (
92
+ json.dumps(manifest, indent=2) + "\n"
93
+ ).encode()
94
+ same_shader = (
95
+ shader_source is None
96
+ or (shader_path is not None
97
+ and shader_path.read_bytes() == shader_source.encode())
98
+ )
99
+ if current == fresh and same_manifest and same_shader:
100
+ return {"spec": spec, "out": out, "atlas": atlas_path, "crc": current,
101
+ "skipped": True}
102
+
103
+ crc = write_png(sheet, atlas_path, png_mode)
104
+ write_manifest(manifest, manifest_path)
105
+ if shader_source is not None and shader_path is not None:
106
+ shader_path.parent.mkdir(parents=True, exist_ok=True)
107
+ shader_path.write_text(shader_source)
108
+ emit_index_ts(manifest, index_path)
109
+ if texturepacker:
110
+ tp = build_texturepacker(spec, records, atlas_name, sheet, png_mode)
111
+ write_texturepacker(tp, tp_path)
112
+ if mipmaps:
113
+ write_mipmap_files(sheet, out, png_mode, mip_meta)
114
+ return {"spec": spec, "out": out, "atlas": atlas_path, "crc": crc, "skipped": False}
115
+
116
+
117
+ @app.command()
118
+ def generate(
119
+ spec: Path = typer.Argument(..., help="spec.json a generar"),
120
+ out: Path = typer.Option(None, "--out", "-o", help="directorio de salida (default: junto a la spec)"),
121
+ seed: int = typer.Option(None, "--seed", "-s", help=f"sobreescribe el seed de la spec"),
122
+ skip_existing: bool = typer.Option(False, "--skip-existing", help="no reescribe si el atlas ya existe con el mismo crc"),
123
+ png_mode: str = typer.Option("rgba", "--png-mode", help="formato del atlas: rgba | png8 | png24"),
124
+ texturepacker: bool = typer.Option(False, "--texturepacker", help="emite además <name>.tpsheet.json (formato TexturePacker JSON Hash)"),
125
+ mipmaps: bool = typer.Option(False, "--mipmaps", help="genera una cadena de mip levels del atlas (@0.5x, @0.25x, ...)"),
126
+ mipmap_levels: int = typer.Option(3, "--mipmap-levels", help="cantidad máxima de niveles de mip (con --mipmaps)"),
127
+ ) -> None:
128
+ """Genera spritesheet + manifest.json + index.ts desde una spec."""
129
+ if png_mode not in PNG_MODES:
130
+ typer.secho(f"--png-mode inválido '{png_mode}' (disponibles: {', '.join(PNG_MODES)})",
131
+ fg=typer.colors.RED, err=True)
132
+ raise typer.Exit(1)
133
+ try:
134
+ r = _generate(spec, out, seed, skip_existing, png_mode, texturepacker, mipmaps, mipmap_levels)
135
+ except SpecError as e:
136
+ typer.secho(f"error en {spec}: {e}", fg=typer.colors.RED, err=True)
137
+ raise typer.Exit(1)
138
+
139
+ s: Spec = r["spec"]
140
+ if r["skipped"]:
141
+ typer.secho(f"[{s.name}] sin cambios (crc {r['crc']:08x}) — skip", fg=typer.colors.YELLOW)
142
+ return
143
+ typer.secho(
144
+ f"[{s.name}] seed={s.seed} frames={s.total_frames} "
145
+ f"sheet={s.layout.cols}x{s.layout.resolve_rows(s.total_frames)} grid"
146
+ f" -> {r['out'].resolve()}",
147
+ fg=typer.colors.GREEN,
148
+ )
149
+ typer.secho(f" atlas.png crc=0x{r['crc']:08x}", fg=typer.colors.BRIGHT_BLACK)
150
+
151
+
152
+ @app.command()
153
+ def batch(
154
+ specs_dir: Path = typer.Argument(..., help="directorio (o glob) con specs"),
155
+ out: Path = typer.Option(None, "--out", "-o", help="directorio de salida común"),
156
+ skip_existing: bool = typer.Option(False, "--skip-existing", help="no reescribe atlases sin cambios"),
157
+ png_mode: str = typer.Option("rgba", "--png-mode", help="formato del atlas: rgba | png8 | png24"),
158
+ texturepacker: bool = typer.Option(False, "--texturepacker", help="emite además <name>.tpsheet.json (formato TexturePacker JSON Hash)"),
159
+ mipmaps: bool = typer.Option(False, "--mipmaps", help="genera una cadena de mip levels del atlas (@0.5x, @0.25x, ...)"),
160
+ mipmap_levels: int = typer.Option(3, "--mipmap-levels", help="cantidad máxima de niveles de mip (con --mipmaps)"),
161
+ ) -> None:
162
+ """Genera todas las specs de un directorio (patrón *.json)."""
163
+ if png_mode not in PNG_MODES:
164
+ typer.secho(f"--png-mode inválido '{png_mode}' (disponibles: {', '.join(PNG_MODES)})",
165
+ fg=typer.colors.RED, err=True)
166
+ raise typer.Exit(1)
167
+ files = sorted(specs_dir.glob("*.json"))
168
+ if not files:
169
+ typer.secho(f"no hay specs (*.json) en {specs_dir}", fg=typer.colors.RED, err=True)
170
+ raise typer.Exit(1)
171
+ failed = 0
172
+ for f in files:
173
+ try:
174
+ _generate(f, out, None, skip_existing, png_mode, texturepacker, mipmaps, mipmap_levels)
175
+ except SpecError as e:
176
+ typer.secho(f"error en {f}: {e}", fg=typer.colors.RED, err=True)
177
+ failed += 1
178
+ typer.echo(f"[batch] {len(files) - failed}/{len(files)} specs ok")
179
+ if failed:
180
+ raise typer.Exit(1)
181
+
182
+
183
+ @app.command()
184
+ def validate(spec: Path = typer.Argument(..., help="spec.json a validar")) -> None:
185
+ """Valida la estructura y los plug-ins de una spec."""
186
+ try:
187
+ s = load_spec(spec)
188
+ except SpecError as e:
189
+ typer.secho(f"inválida: {e}", fg=typer.colors.RED, err=True)
190
+ raise typer.Exit(1)
191
+ typer.secho(
192
+ f"[{s.name}] OK — items={[i.id for i in s.items]} "
193
+ f"frames={s.total_frames} seed={s.seed}",
194
+ fg=typer.colors.GREEN,
195
+ )
196
+
197
+
198
+ @app.command()
199
+ def info(
200
+ spec: Path = typer.Argument(..., help="spec.json a inspeccionar"),
201
+ as_json: bool = typer.Option(False, "--json", help="salida machine-readable"),
202
+ ) -> None:
203
+ """Reporte de una spec: items, frames, layout y tamaño estimado del atlas."""
204
+ try:
205
+ s = load_spec(spec)
206
+ except SpecError as e:
207
+ typer.secho(f"inválida: {e}", fg=typer.colors.RED, err=True)
208
+ raise typer.Exit(1)
209
+
210
+ cols = s.layout.cols
211
+ rows = s.layout.resolve_rows(s.total_frames)
212
+ fpx = s.layout.frame_px
213
+ atlas_w, atlas_h = cols * fpx, rows * fpx
214
+
215
+ if as_json:
216
+ typer.echo(json.dumps({
217
+ "name": s.name,
218
+ "seed": s.seed,
219
+ "target": s.target,
220
+ "spec": str(spec),
221
+ "layout": {
222
+ "framePx": fpx, "cols": cols, "rows": rows,
223
+ "tileLogical": s.layout.tile_logical, "sample": s.layout.sample,
224
+ },
225
+ "atlas": {
226
+ "file": s.filename, "width": atlas_w, "height": atlas_h,
227
+ "frames": s.total_frames,
228
+ },
229
+ "items": [
230
+ {"id": it.id, "generator": it.generator, "frames": it.frames,
231
+ "autotile": it.autotile, "params": it.params}
232
+ for it in s.items
233
+ ],
234
+ "animations": {
235
+ n: {"frames": a.frames, "fps": a.fps, "loop": a.loop}
236
+ for n, a in s.animations.items()
237
+ },
238
+ "runtime": bool(s.runtime),
239
+ }, indent=2))
240
+ return
241
+
242
+ typer.secho(f"[{s.name}] {spec}", fg=typer.colors.GREEN, bold=True)
243
+ typer.echo(f" seed : {s.seed}")
244
+ typer.echo(f" target : {s.target}")
245
+ typer.echo(f" layout : framePx={fpx} cols={cols} rows={rows} "
246
+ f"tileLogical={s.layout.tile_logical} sample={s.layout.sample}")
247
+ typer.echo(f" atlas : {s.filename} {atlas_w}x{atlas_h} ({s.total_frames} frames)")
248
+ typer.echo(f" runtime : {'sí' if s.runtime else 'no'}")
249
+ typer.echo(" items:")
250
+ for it in s.items:
251
+ extra = ""
252
+ if it.autotile:
253
+ extra += f" autotile={it.autotile}"
254
+ keys = sorted(k for k in it.params if k != "autotile")
255
+ if keys:
256
+ extra += f" params[{','.join(keys)}]"
257
+ typer.echo(f" - {it.id:<14} {it.generator:<10} frames={it.frames}{extra}")
258
+ if s.animations:
259
+ typer.echo(" anim:")
260
+ for name, a in s.animations.items():
261
+ typer.echo(f" - {name:<14} frames={a.frames} fps={a.fps} loop={a.loop}")
262
+
263
+
264
+ PADDING_WARN_RATIO = 0.25
265
+
266
+
267
+ def _lint_warnings(spec: Spec, items_frames: list[list[FrameData]]) -> list[dict]:
268
+ """Advertencias de calidad de una spec ya renderizada: padding de atlas
269
+ (celdas de grilla sin usar) y frames completamente transparentes."""
270
+ warnings: list[dict] = []
271
+
272
+ cols = spec.layout.cols
273
+ rows = spec.layout.resolve_rows(spec.total_frames)
274
+ capacity = cols * rows
275
+ unused = capacity - spec.total_frames
276
+ if capacity and unused / capacity > PADDING_WARN_RATIO:
277
+ warnings.append({
278
+ "check": "padding",
279
+ "message": f"{unused}/{capacity} celdas del atlas sin usar ({unused / capacity:.0%})",
280
+ })
281
+
282
+ empty_ids = [
283
+ fr.id
284
+ for item, frames in zip(spec.items, items_frames, strict=True)
285
+ # el generador `font` produce glifos vacíos a propósito (el espacio
286
+ # no pinta ningún píxel) — no es un frame desperdiciado.
287
+ if item.generator != "font"
288
+ for fr in frames
289
+ # los tiles RGB (p. ej. terrain sin autotile) son opacos por diseño
290
+ # y no tienen canal alpha que consultar.
291
+ if fr.image.mode == "RGBA" and fr.image.getchannel("A").getbbox() is None
292
+ ]
293
+ if empty_ids:
294
+ warnings.append({
295
+ "check": "empty_frames",
296
+ "message": f"{len(empty_ids)} frame(s) completamente transparentes",
297
+ "ids": empty_ids,
298
+ })
299
+
300
+ return warnings
301
+
302
+
303
+ @app.command()
304
+ def lint(
305
+ spec: Path = typer.Argument(..., help="spec.json a analizar"),
306
+ as_json: bool = typer.Option(False, "--json", help="salida machine-readable"),
307
+ ) -> None:
308
+ """Analiza una spec: padding de atlas y frames vacíos (renderiza para verificar)."""
309
+ try:
310
+ s = load_spec(spec)
311
+ items_frames = render_items(s)
312
+ except SpecError as e:
313
+ typer.secho(f"inválida: {e}", fg=typer.colors.RED, err=True)
314
+ raise typer.Exit(1)
315
+
316
+ warnings = _lint_warnings(s, items_frames)
317
+ if as_json:
318
+ typer.echo(json.dumps({"spec": str(spec), "warnings": warnings}, indent=2))
319
+ elif not warnings:
320
+ typer.secho(f"[{s.name}] OK — sin advertencias", fg=typer.colors.GREEN)
321
+ else:
322
+ typer.secho(f"[{s.name}] {len(warnings)} advertencia(s):", fg=typer.colors.YELLOW)
323
+ for w in warnings:
324
+ typer.echo(f" - [{w['check']}] {w['message']}")
325
+ raise typer.Exit(1 if warnings else 0)
326
+
327
+
328
+ def _diff_specs(a: Spec, b: Spec) -> list[dict]:
329
+ """Diferencias estructurales entre dos specs: `[{"field", "a", "b"}, ...]`."""
330
+ diffs: list[dict] = []
331
+
332
+ for field in ("name", "seed", "target"):
333
+ av, bv = getattr(a, field), getattr(b, field)
334
+ if av != bv:
335
+ diffs.append({"field": field, "a": av, "b": bv})
336
+
337
+ for f in ("frame_px", "cols", "tile_logical", "sample"):
338
+ av, bv = getattr(a.layout, f), getattr(b.layout, f)
339
+ if av != bv:
340
+ diffs.append({"field": f"layout.{f}", "a": av, "b": bv})
341
+
342
+ a_items = {i.id: i for i in a.items}
343
+ b_items = {i.id: i for i in b.items}
344
+ for iid in sorted(set(b_items) - set(a_items)):
345
+ diffs.append({"field": f"items.{iid}", "a": None, "b": "added"})
346
+ for iid in sorted(set(a_items) - set(b_items)):
347
+ diffs.append({"field": f"items.{iid}", "a": "removed", "b": None})
348
+ for iid in sorted(set(a_items) & set(b_items)):
349
+ ia, ib = a_items[iid], b_items[iid]
350
+ if (ia.generator, ia.frames, ia.params) != (ib.generator, ib.frames, ib.params):
351
+ diffs.append({
352
+ "field": f"items.{iid}",
353
+ "a": {"generator": ia.generator, "frames": ia.frames, "params": ia.params},
354
+ "b": {"generator": ib.generator, "frames": ib.frames, "params": ib.params},
355
+ })
356
+
357
+ a_anim, b_anim = a.animations, b.animations
358
+ for name in sorted(set(b_anim) - set(a_anim)):
359
+ diffs.append({"field": f"animations.{name}", "a": None, "b": "added"})
360
+ for name in sorted(set(a_anim) - set(b_anim)):
361
+ diffs.append({"field": f"animations.{name}", "a": "removed", "b": None})
362
+ for name in sorted(set(a_anim) & set(b_anim)):
363
+ ia, ib = a_anim[name], b_anim[name]
364
+ if (ia.frames, ia.fps, ia.loop) != (ib.frames, ib.fps, ib.loop):
365
+ diffs.append({
366
+ "field": f"animations.{name}",
367
+ "a": {"frames": ia.frames, "fps": ia.fps, "loop": ia.loop},
368
+ "b": {"frames": ib.frames, "fps": ib.fps, "loop": ib.loop},
369
+ })
370
+
371
+ return diffs
372
+
373
+
374
+ def _diff_outputs(a: Path, b: Path) -> list[dict]:
375
+ """Diferencias entre dos directorios de salida: CRC del atlas + manifest.json."""
376
+ diffs: list[dict] = []
377
+
378
+ atlas_a, atlas_b = a / "atlas.png", b / "atlas.png"
379
+ if atlas_a.is_file() and atlas_b.is_file():
380
+ crc_a = zlib.crc32(atlas_a.read_bytes()) & 0xFFFFFFFF
381
+ crc_b = zlib.crc32(atlas_b.read_bytes()) & 0xFFFFFFFF
382
+ if crc_a != crc_b:
383
+ diffs.append({"field": "atlas.crc", "a": f"{crc_a:08x}", "b": f"{crc_b:08x}"})
384
+ else:
385
+ diffs.append({"field": "atlas.png", "a": atlas_a.is_file(), "b": atlas_b.is_file()})
386
+
387
+ man_a_p, man_b_p = a / "manifest.json", b / "manifest.json"
388
+ if not (man_a_p.is_file() and man_b_p.is_file()):
389
+ diffs.append({"field": "manifest.json", "a": man_a_p.is_file(), "b": man_b_p.is_file()})
390
+ return diffs
391
+
392
+ man_a = json.loads(man_a_p.read_text())
393
+ man_b = json.loads(man_b_p.read_text())
394
+
395
+ for field in ("name", "seed"):
396
+ if man_a.get(field) != man_b.get(field):
397
+ diffs.append({"field": field, "a": man_a.get(field), "b": man_b.get(field)})
398
+
399
+ size_a = (man_a["files"]["atlasW"], man_a["files"]["atlasH"])
400
+ size_b = (man_b["files"]["atlasW"], man_b["files"]["atlasH"])
401
+ if size_a != size_b:
402
+ diffs.append({"field": "atlas.size", "a": list(size_a), "b": list(size_b)})
403
+
404
+ ids_a = {f["id"] for f in man_a["frames"]}
405
+ ids_b = {f["id"] for f in man_b["frames"]}
406
+ if ids_b - ids_a:
407
+ diffs.append({"field": "frames.added", "a": None, "b": sorted(ids_b - ids_a)})
408
+ if ids_a - ids_b:
409
+ diffs.append({"field": "frames.removed", "a": sorted(ids_a - ids_b), "b": None})
410
+
411
+ for block in ("autotile", "shader", "font"):
412
+ if (block in man_a) != (block in man_b):
413
+ diffs.append({"field": f"{block}.present", "a": block in man_a, "b": block in man_b})
414
+
415
+ return diffs
416
+
417
+
418
+ @app.command()
419
+ def diff(
420
+ a: Path = typer.Argument(..., help="spec.json o directorio de salida A"),
421
+ b: Path = typer.Argument(..., help="spec.json o directorio de salida B"),
422
+ as_json: bool = typer.Option(False, "--json", help="salida machine-readable"),
423
+ ) -> None:
424
+ """Compara dos specs (.json) o dos directorios de salida (atlas+manifest)."""
425
+ a_is_spec, b_is_spec = a.suffix == ".json", b.suffix == ".json"
426
+ if a_is_spec != b_is_spec:
427
+ typer.secho("no se puede comparar una spec (.json) con un directorio de salida",
428
+ fg=typer.colors.RED, err=True)
429
+ raise typer.Exit(1)
430
+
431
+ try:
432
+ if a_is_spec:
433
+ kind, diffs = "spec", _diff_specs(load_spec(a), load_spec(b))
434
+ else:
435
+ if not a.is_dir() or not b.is_dir():
436
+ typer.secho(f"directorio no encontrado: {a if not a.is_dir() else b}",
437
+ fg=typer.colors.RED, err=True)
438
+ raise typer.Exit(1)
439
+ kind, diffs = "output", _diff_outputs(a, b)
440
+ except SpecError as e:
441
+ typer.secho(f"inválida: {e}", fg=typer.colors.RED, err=True)
442
+ raise typer.Exit(1)
443
+
444
+ if as_json:
445
+ typer.echo(json.dumps({"a": str(a), "b": str(b), "kind": kind, "diffs": diffs}, indent=2))
446
+ elif not diffs:
447
+ typer.secho(f"sin diferencias ({kind})", fg=typer.colors.GREEN)
448
+ else:
449
+ typer.secho(f"{len(diffs)} diferencia(s) ({kind}):", fg=typer.colors.YELLOW)
450
+ for d in diffs:
451
+ typer.echo(f" - {d['field']}: {d['a']!r} -> {d['b']!r}")
452
+ raise typer.Exit(1 if diffs else 0)
453
+
454
+
455
+ @app.command()
456
+ def watch(
457
+ specs_dir: Path = typer.Argument(..., help="directorio con specs (*.json)"),
458
+ out: Path = typer.Option(None, "--out", "-o", help="directorio de salida (default: junto a cada spec)"),
459
+ interval: float = typer.Option(1.0, "--interval", "-i", help="sondaje de cambios en segundos"),
460
+ ) -> None:
461
+ """Vigila specs/ y regenera assets cuando cambian (Ctrl-C para detener)."""
462
+ if not specs_dir.is_dir():
463
+ typer.secho(f"no existe el directorio: {specs_dir}", fg=typer.colors.RED, err=True)
464
+ raise typer.Exit(1)
465
+ files = sorted(specs_dir.glob("*.json"))
466
+ if not files:
467
+ typer.secho(f"no hay specs (*.json) en {specs_dir}", fg=typer.colors.RED, err=True)
468
+ raise typer.Exit(1)
469
+
470
+ typer.secho(
471
+ f"[watch] {specs_dir} -> {out or specs_dir} cada {interval}s "
472
+ f"({len(files)} specs) — Ctrl-C para salir",
473
+ fg=typer.colors.CYAN,
474
+ )
475
+ mtimes: dict[Path, float] = {}
476
+ primera = True
477
+ try:
478
+ while True:
479
+ for f in sorted(specs_dir.glob("*.json")):
480
+ try:
481
+ mt = f.stat().st_mtime
482
+ except OSError:
483
+ continue
484
+ if mtimes.get(f, 0.0) >= mt:
485
+ continue
486
+ mtimes[f] = mt
487
+ if not primera:
488
+ typer.secho(f" [cambio] {f.name}", fg=typer.colors.BLUE)
489
+ try:
490
+ r = _generate(f, out, None, True)
491
+ except SpecError as e:
492
+ typer.secho(f" [error] {f.name}: {e}", fg=typer.colors.RED, err=True)
493
+ continue
494
+ s: Spec = r["spec"]
495
+ if r["skipped"]:
496
+ typer.secho(f" [skip] {s.name} sin cambios (crc {r['crc']:08x})",
497
+ fg=typer.colors.YELLOW)
498
+ else:
499
+ typer.secho(
500
+ f" [ok] {s.name} seed={s.seed} frames={s.total_frames} "
501
+ f"-> {r['out'].resolve()} (crc {r['crc']:08x})",
502
+ fg=typer.colors.GREEN,
503
+ )
504
+ primera = False
505
+ time.sleep(interval)
506
+ except KeyboardInterrupt:
507
+ typer.secho("\n[watch] detenido.", fg=typer.colors.CYAN)
508
+
509
+
510
+ @app.callback(invoke_without_command=True)
511
+ def _version(version: bool = typer.Option(False, "--version", help="muestra la versión")) -> None:
512
+ if version:
513
+ typer.echo(f"sprout {__version__}")
514
+ raise typer.Exit()
515
+
516
+
517
+ if __name__ == "__main__":
518
+ app()