From 75a6dba73d3e1491f0aac77500c6f1305d64fcad Mon Sep 17 00:00:00 2001 From: BillyOutlast <172061051+BillyOutlast@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:21:58 -0900 Subject: [PATCH] All three Godot phases: preset write path, project scanner + browser UI, container ffmpeg --- Dockerfile | 2 +- actions/godot/set_preset/set_preset.py | 93 +++++++++++++++ actions/godot/set_preset/set_preset.yaml | 18 +++ apsync/__init__.py | 2 +- assetmgr/cli.py | 28 +++++ assetmgr/godot.py | 139 +++++++++++++++++++++++ assetmgr/scan.py | 105 +++++++++++++++++ native/main.go | 18 +++ native/main_test.go | 66 +++++++++++ native/web/index.html | 51 +++++++++ tests/test_godot.py | 91 ++++++++++++++- tests/test_scan.py | 132 +++++++++++++++++++++ tests/test_thumbnails.py | 55 +++++++++ 13 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 actions/godot/set_preset/set_preset.py create mode 100644 actions/godot/set_preset/set_preset.yaml create mode 100644 assetmgr/scan.py create mode 100644 tests/test_scan.py create mode 100644 tests/test_thumbnails.py diff --git a/Dockerfile b/Dockerfile index 1705cb7..ab67bff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN CGO_ENABLED=0 go build -trimpath -o /out/assetmgrd . FROM debian:trixie-slim ARG GODOT_VERSION=4.3-stable RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 python3-yaml git ca-certificates curl unzip \ + && apt-get install -y --no-install-recommends python3 python3-yaml git ca-certificates curl unzip ffmpeg \ && rm -rf /var/lib/apt/lists/* \ && curl -sL -o /tmp/godot.zip "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_linux.x86_64.zip" \ && unzip -q /tmp/godot.zip -d /tmp/godot-unzip \ diff --git a/actions/godot/set_preset/set_preset.py b/actions/godot/set_preset/set_preset.py new file mode 100644 index 0000000..17d4edc --- /dev/null +++ b/actions/godot/set_preset/set_preset.py @@ -0,0 +1,93 @@ +"""Apply an import preset: update [params] in the asset's *.import file.""" + +import json +import os + +import anchorpoint +from assetmgr.godot import ( + find_godot_project_root, + parse_godot_value, + set_import_params, +) + + +def _parse_params(raw: object) -> dict: + """Accept a dict, a JSON object, or ``key=value`` lines.""" + if isinstance(raw, dict): + return dict(raw) + text = str(raw or "").strip() + if not text: + return {} + if text.startswith("{"): + data = json.loads(text) + if not isinstance(data, dict): + raise ValueError("params JSON must be an object") + return dict(data) + params: dict = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith(";") or line.startswith("#"): + continue + if "=" not in line: + raise ValueError(f"bad params line (want key=value): {line!r}") + key, _, value = line.partition("=") + key = key.strip() + if not key: + raise ValueError(f"bad params line (want key=value): {line!r}") + params[key] = parse_godot_value(value.strip()) + return params + + +ctx = anchorpoint.get_context() +selection = [*(ctx.selected_files or []), *(ctx.selected_folders or [])] +if ctx.path and ctx.path not in selection: + selection.append(ctx.path) + +target = str(ctx.inputs.get("target", "") or "").strip() +asset = target or (selection[0] if selection else "") + +if asset.startswith("res://"): + asset = asset[len("res://"):] + +root = None +if ctx.project_path: + root = find_godot_project_root(ctx.project_path) +if root is None and asset: + root = find_godot_project_root(asset) +if root is None: + anchorpoint.UI().show_error("Not a Godot project", + "No project.godot found above the selection.") + raise SystemExit(1) + +if asset and not os.path.isabs(asset): + asset = os.path.join(root, asset) +if asset.endswith(".import"): + import_path = asset +elif asset and os.path.isdir(asset): + anchorpoint.UI().show_error("Set import preset failed", + f"{asset!r} is a folder; select an asset file.") + raise SystemExit(1) +else: + import_path = (asset + ".import") if asset else "" +if not import_path or not os.path.isfile(import_path): + anchorpoint.UI().show_error( + "Set import preset failed", + f"No *.import sidecar for {asset or 'empty selection'!r} " + "(reimport the project first).") + raise SystemExit(1) + +try: + params = _parse_params(ctx.inputs.get("params", "")) +except ValueError as exc: + anchorpoint.UI().show_error("Set import preset failed", str(exc)) + raise SystemExit(1) +if not params: + anchorpoint.UI().show_error("Set import preset failed", + "No import params given in the 'params' input.") + raise SystemExit(1) + +written = set_import_params(import_path, params) +anchorpoint.UI().show_success( + "Import preset updated", + f"{len(written)} param(s) in {os.path.basename(import_path)} " + "(run godot::reimport to refresh the editor cache)") diff --git a/actions/godot/set_preset/set_preset.yaml b/actions/godot/set_preset/set_preset.yaml new file mode 100644 index 0000000..a98a72a --- /dev/null +++ b/actions/godot/set_preset/set_preset.yaml @@ -0,0 +1,18 @@ +version: "1.0" +action: + name: Set Godot import preset + id: godot::set-preset + version: 1 + author: Heretek-AI + category: godot + type: python + description: Updates [params] keys in the selected asset's *.import sidecar (e.g. compress/lossy_quality), preserving everything else byte-for-byte. Run godot::reimport afterwards to refresh the editor cache. + script: set_preset.py + inputs: + target: "" + params: "compress/mode=1\ncompress/lossy_quality=0.9" + register: + file: + enable: true + folder: + enable: true diff --git a/apsync/__init__.py b/apsync/__init__.py index b37a290..acd8d45 100644 --- a/apsync/__init__.py +++ b/apsync/__init__.py @@ -119,7 +119,7 @@ def generate_thumbnail(source: str, destination: str) -> str: continue import subprocess - result = subprocess.run(args, capture_output=True, text=True) + result = subprocess.run([tool, *args], capture_output=True, text=True) if result.returncode == 0 and os.path.exists(destination): return destination raise RuntimeError(f"{tool} failed: {result.stderr.strip()[:300]}") diff --git a/assetmgr/cli.py b/assetmgr/cli.py index 779d98f..d031fae 100644 --- a/assetmgr/cli.py +++ b/assetmgr/cli.py @@ -16,7 +16,9 @@ from contextlib import redirect_stdout from assetmgr import __version__ from assetmgr.actions import discover +from assetmgr.godot import find_godot_project_root, get_project_name from assetmgr.host import DispatchError, run_action +from assetmgr.scan import scan_project from assetmgr.store import find_project_root, init_project @@ -77,6 +79,27 @@ def _cmd_project_init(args: argparse.Namespace) -> int: return 0 +def _cmd_project_scan(args: argparse.Namespace) -> int: + start = os.path.abspath(args.root) + project_root = find_godot_project_root(start) + if project_root is None: + if args.json: + print(json.dumps({"root": start, "error": "not a godot project"})) + else: + print(f"not a godot project: {start}", file=sys.stderr) + return 1 + assets = scan_project(project_root) + if args.json: + print(json.dumps({"root": project_root, + "project_name": get_project_name(project_root), + "assets": assets})) + else: + for asset in assets: + print(f"{asset['path']} {asset['size_bytes']} bytes") + print(f"{len(assets)} asset(s) in {project_root}") + return 0 + + def _cmd_run(args: argparse.Namespace) -> int: found, _errors = discover(args.root) wanted = [m for m in found if m.manifest_id == args.action_id] @@ -172,6 +195,11 @@ def build_parser() -> argparse.ArgumentParser: _add_json(p) p.set_defaults(func=_cmd_project_init) + p = sub.add_parser("project-scan", help="scan a Godot project for assets") + p.add_argument("root", help="project root (or anything inside it)") + _add_json(p) + p.set_defaults(func=_cmd_project_scan) + p = sub.add_parser("run", help="run one action by id") p.add_argument("action_id") p.add_argument("--root", default="actions") diff --git a/assetmgr/godot.py b/assetmgr/godot.py index 83628b4..4d66fb6 100644 --- a/assetmgr/godot.py +++ b/assetmgr/godot.py @@ -225,6 +225,145 @@ def read_import(import_path: str) -> dict[str, dict[str, Any]]: return parse_godot_config(handle.read()) +def _escape_godot_string(text: str) -> str: + """Escape a string for Godot ConfigFile one-line form.""" + out: list[str] = [] + for char in text: + if char == "\\": + out.append("\\\\") + elif char == '"': + out.append('\\"') + elif char == "\n": + out.append("\\n") + elif char == "\t": + out.append("\\t") + elif char == "\r": + out.append("\\r") + elif ord(char) < 0x20: + out.append(f"\\u{ord(char):04x}") + else: + out.append(char) + return "".join(out) + + +def format_godot_value(value: object) -> str: + """Serialize a Python value to Godot ConfigFile one-line form.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return repr(value) + if isinstance(value, (list, tuple)): + return "[" + ", ".join(format_godot_value(item) for item in value) + "]" + return f'"{_escape_godot_string(str(value))}"' + + +def _split_line_ending(line: str) -> tuple[str, str]: + """Split a kept-ends line into ``(content, ending)``.""" + if line.endswith("\r\n"): + return line[:-2], "\r\n" + if line.endswith("\n") or line.endswith("\r"): + return line[:-1], line[-1:] + return line, "" + + +def set_import_params(import_path: str, params: dict[str, object]) -> list[str]: + """Update keys in the ``[params]`` section of a ``*.import`` file. + + Every other byte is preserved: comments, blank lines, section order, + key order and unrelated values are untouched; only targeted key lines + change, and keys missing from ``[params]`` are appended at its end + (a missing ``[params]`` section is appended at end of file). + + Returns the keys that were written. + """ + with open(import_path, encoding="utf-8", newline="") as handle: + text = handle.read() + parts = [_split_line_ending(line) + for line in text.splitlines(keepends=True)] + + section = "" + pending: list | None = None # [key, start line, end line (excl.)] + spans: dict[str, list[tuple[int, int]]] = {} + header_idx: int | None = None + end_idx: int | None = None + + def _flush() -> None: + nonlocal pending + if pending is not None: + spans.setdefault(pending[0], []).append((pending[1], pending[2])) + pending = None + + for index, (content, _ending) in enumerate(parts): + stripped = content.strip() + section_match = _SECTION_RE.match(stripped) if stripped else None + if section_match: + _flush() + section = section_match.group(1) + if section == "params": + if header_idx is None: + header_idx = index + elif header_idx is not None and end_idx is None: + end_idx = index + continue + if not stripped or stripped.startswith(";"): + continue + if section and _KEY_RE.match(stripped): + _flush() + if section == "params": + pending = [stripped.partition("=")[0].strip(), + index, index + 1] + continue + if pending is not None: + pending[2] = index + 1 + _flush() + if end_idx is None: + end_idx = len(parts) + + if not params: + return [] + + rows = [list(part) for part in parts] + removals: set[int] = set() + if header_idx is None: + if rows == [["", ""]]: + rows = [] + elif rows and rows[-1][1] == "": + rows[-1][1] = "\n" + header_idx = len(rows) + rows.append(["[params]", "\n"]) + end_idx = len(rows) + ending = "\r\n" if any(line_ending == "\r\n" + for _, line_ending in rows) else "\n" + if rows and rows[-1][1] == "": + rows[-1][1] = ending + insert_at = end_idx + while insert_at > header_idx + 1 and rows[insert_at - 1][0] == "": + insert_at -= 1 # keep the block's trailing blank line last + for key, value in params.items(): + updated = f"{key}={format_godot_value(value)}" + if key in spans: + for start, end in spans[key]: + rows[start] = [updated, rows[start][1] or "\n"] + removals.update(range(start + 1, end)) + else: + rows.insert(insert_at, [updated, ending]) + for other_spans in spans.values(): + for pos, (start, end) in enumerate(other_spans): + if start >= insert_at: + other_spans[pos] = (start + 1, end + 1) + removals = {i + 1 if i >= insert_at else i for i in removals} + insert_at += 1 + + kept = [content + line_ending + for i, (content, line_ending) in enumerate(rows) + if i not in removals] + with open(import_path, "w", encoding="utf-8", newline="") as handle: + handle.write("".join(kept)) + return list(params) + + def import_summary(parsed: dict[str, dict[str, Any]]) -> dict[str, object]: """Condense a parsed ``*.import`` to importer/type/uid/files/params.""" remap = parsed.get("remap", {}) diff --git a/assetmgr/scan.py b/assetmgr/scan.py new file mode 100644 index 0000000..75696da --- /dev/null +++ b/assetmgr/scan.py @@ -0,0 +1,105 @@ +"""Godot project scanner: list every asset under ``res://`` with sidecar state. + +For each regular file under the project root (excluding the ``.godot/`` +editor cache and ``*.import`` / ``*.uid`` / ``*.meta.json`` sidecars), +:func:`scan_project` reports a JSON-serializable dict:: + + {"path": "res://art/hero.png", "name": "hero.png", "suffix": ".png", + "size_bytes": 1234, "mtime": 1720000000.0, + "has_import": True, "import": {...} | None, + "uid": "uid://..." | None, "attributes": {...}} + +Import summaries, uid reads and ``.meta.json`` attributes reuse the +:mod:`assetmgr.godot` and :mod:`assetmgr.store` helpers, so the on-disk +formats stay defined in exactly one place each. +""" + +from __future__ import annotations + +import os +from typing import Any + +from assetmgr.godot import ( + GODOT_DIR, + find_godot_project_root, + import_summary, + read_import, + read_uid, +) +from assetmgr.store import read_sidecar + +#: Sidecar suffixes: metadata about an asset, not assets themselves. +SKIP_SUFFIXES = (".import", ".uid", ".meta.json") + + +def _res_path(project_root: str, absolute: str) -> str: + relative = os.path.relpath(absolute, project_root).replace(os.sep, "/") + return f"res://{relative}" + + +def _is_cache_dir(project_root: str, dirpath: str) -> bool: + return GODOT_DIR in os.path.relpath(dirpath, project_root).split(os.sep) + + +def scan_project(root: str) -> list[dict[str, Any]]: + """List asset files under the Godot project holding *root*. + + *root* may be the project root itself or anything inside it; the + enclosing project (walked up via ``project.godot``) is scanned. + Raises :class:`FileNotFoundError` when *root* does not exist or is + not inside a Godot project. + """ + start = os.path.abspath(root) + if not os.path.exists(start): + raise FileNotFoundError(start) + project_root = find_godot_project_root(start) + if project_root is None: + raise FileNotFoundError(f"not a Godot project: {start}") + + assets: list[dict[str, Any]] = [] + for dirpath, dirnames, filenames in os.walk(project_root): + dirnames[:] = sorted(d for d in dirnames if d != GODOT_DIR) + if _is_cache_dir(project_root, dirpath): + continue + for filename in sorted(filenames): + if filename.endswith(SKIP_SUFFIXES): + continue + absolute = os.path.join(dirpath, filename) + try: + stat = os.stat(absolute) + except OSError: + continue # broken link or vanishing file; not an asset + if not os.path.isfile(absolute): + continue + import_path = f"{absolute}.import" + has_import = os.path.isfile(import_path) + parsed_import: dict[str, Any] | None = None + if has_import: + try: + parsed_import = import_summary(read_import(import_path)) # type: ignore[assignment] + except OSError: + parsed_import = None + uid_path = f"{absolute}.uid" + uid: str | None = None + if os.path.isfile(uid_path): + try: + uid = read_uid(uid_path) + except OSError: + uid = None + try: + attributes = read_sidecar(absolute) + except (OSError, ValueError): + attributes = {} + assets.append({ + "path": _res_path(project_root, absolute), + "name": filename, + "suffix": os.path.splitext(filename)[1].lower(), + "size_bytes": stat.st_size, + "mtime": stat.st_mtime, + "has_import": has_import, + "import": parsed_import, + "uid": uid, + "attributes": attributes, + }) + assets.sort(key=lambda item: item["path"]) + return assets diff --git a/native/main.go b/native/main.go index 3764dcd..bd17257 100644 --- a/native/main.go +++ b/native/main.go @@ -185,6 +185,24 @@ func (s *Server) routes() *http.ServeMux { writeRaw(w, http.StatusOK, raw) }) + mux.HandleFunc("GET /api/scan", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + if path == "" { + writeErr(w, http.StatusBadRequest, "path is required") + return + } + raw, code, err := s.cli("project-scan", path) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if code != 0 { + writeRaw(w, http.StatusNotFound, raw) + return + } + writeRaw(w, http.StatusOK, raw) + }) + sub, err := fs.Sub(webFS, "web") if err != nil { log.Fatalf("embedded web UI missing: %v", err) diff --git a/native/main_test.go b/native/main_test.go index 21a1d35..dfb31ba 100644 --- a/native/main_test.go +++ b/native/main_test.go @@ -128,3 +128,69 @@ func TestProjectInitRequiresFields(t *testing.T) { t.Fatalf("expected 400, got %d", code) } } + +func makeGodotProject(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "project.godot"), + []byte("config_version=5\n\n[application]\n\nconfig/name=\"Scan Game\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "art"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "art", "hero.png"), + []byte("png-bytes"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "art", "hero.png.import"), + []byte("[remap]\n\nimporter=\"texture\"\ntype=\"CompressedTexture2D\"\n"), 0o644); err != nil { + t.Fatal(err) + } + return root +} + +func TestScan(t *testing.T) { + s := testServer(t) + project := makeGodotProject(t) + plain := t.TempDir() + + tests := []struct { + name string + query string + wantCode int + }{ + {"valid project", project, 200}, + {"not a godot project", plain, 404}, + {"missing path", "", 400}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + code, body := get(t, s, "/api/scan?path="+tc.query) + if code != tc.wantCode { + t.Fatalf("expected %d, got %d (%v)", tc.wantCode, code, body) + } + if tc.wantCode != 200 { + return + } + assets, ok := body["assets"].([]any) + if !ok || len(assets) == 0 { + t.Fatalf("expected assets, got %v", body) + } + paths := map[string]bool{} + for _, a := range assets { + entry := a.(map[string]any) + paths[entry["path"].(string)] = true + if entry["path"] == "res://art/hero.png" && entry["has_import"] != true { + t.Fatalf("hero.png should report has_import: %v", entry) + } + } + if !paths["res://art/hero.png"] { + t.Fatalf("hero.png missing: %v", paths) + } + if body["project_name"] != "Scan Game" { + t.Fatalf("bad project_name: %v", body) + } + }) + } +} diff --git a/native/web/index.html b/native/web/index.html index a9dfaf4..9404357 100644 --- a/native/web/index.html +++ b/native/web/index.html @@ -18,6 +18,7 @@ button { cursor: pointer; } button:hover { background: #2c3138; } .row { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } pre { background: #0c0e11; padding: 12px; border-radius: 6px; overflow: auto; } .muted { color: #999; } +.badge { display: inline-block; background: #24344d; border: 1px solid #3a5a8c; border-radius: 10px; padding: 1px 8px; font-size: 0.85em; } @@ -39,6 +40,14 @@ pre { background: #0c0e11; padding: 12px; border-radius: 6px; overflow: auto; } +

Project browser

+
+ + +
+ +
PathSizeImportDetails
No scan yet.
+

Status

@@ -95,6 +104,48 @@ async function showStatus() { const {status, body} = await api('/api/status?path=' + encodeURIComponent(path)); say({http: status, ...body}); } +async function scanProject() { + const path = document.getElementById('scanpath').value; + const {status, body} = await api('/api/scan?path=' + encodeURIComponent(path)); + if (status !== 200) { say({http: status, ...body}); return; } + const tb = document.getElementById('scanrows'); + tb.innerHTML = ''; + for (const a of body.assets) { + const tr = document.createElement('tr'); + const pathTd = document.createElement('td'); + pathTd.textContent = a.path; + const sizeTd = document.createElement('td'); + sizeTd.textContent = a.size_bytes; + const impTd = document.createElement('td'); + if (a.has_import && a.import && a.import.importer) { + const badge = document.createElement('span'); + badge.className = 'badge'; + badge.textContent = a.import.importer; + impTd.appendChild(badge); + } else { + impTd.innerHTML = 'raw'; + } + const detTd = document.createElement('td'); + detTd.textContent = detailText(a); + detTd.title = JSON.stringify({uid: a.uid, params: a.import && a.import.params, + attributes: a.attributes}, null, 2); + tr.append(pathTd, sizeTd, impTd, detTd); + tb.appendChild(tr); + } + if (!body.assets.length) { + tb.innerHTML = 'No assets found.'; + } + say({project: body.project_name, root: body.root, assets: body.assets.length}); +} +function detailText(a) { + const bits = []; + if (a.import && a.import.type) bits.push(a.import.type); + if (a.import && a.import.params) bits.push(Object.keys(a.import.params).length + ' param(s)'); + if (a.uid) bits.push(a.uid); + const keys = a.attributes ? Object.keys(a.attributes) : []; + if (keys.length) bits.push('meta: ' + keys.join(', ')); + return bits.join(' · ') || '—'; +} fetch('/api/info').then(r => r.json()).then(i => { document.getElementById('ver').textContent = 'v' + i.version + ' · ' + i.mode; }).catch(() => {}); diff --git a/tests/test_godot.py b/tests/test_godot.py index 100dee7..753e2ca 100644 --- a/tests/test_godot.py +++ b/tests/test_godot.py @@ -19,6 +19,7 @@ from assetmgr.godot import ( parse_godot_config, read_import, read_uid, + set_import_params, ) from assetmgr.host import run_action @@ -118,7 +119,7 @@ def test_godot_action_manifests_discovered(): assert not errors ids = {m.manifest_id for m in found} assert {"godot::import-asset", "godot::reimport", - "godot::open-project"} <= ids + "godot::open-project", "godot::set-preset"} <= ids def test_import_action_dispatch(tmp_path, capsys): @@ -228,3 +229,91 @@ def test_list_and_read_import_files(tmp_path): handle.write(REAL_TEXTURE_IMPORT) assert list_import_files(root) == [imp] assert read_import(imp)["remap"]["importer"] == "texture" + + +PRESET_IMPORT = """\ +; texture preset, keep me +[remap] + +importer="texture" +type="CompressedTexture2D" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://hero.png" + +[params] + +compress/mode=0 +compress/lossy_quality=0.7 +process/fix_alpha_border=true +roughness/src_normal="" +""" + + +def test_set_import_params_roundtrip_fidelity(tmp_path): + imp = tmp_path / "hero.png.import" + imp.write_text(PRESET_IMPORT, encoding="utf-8") + written = set_import_params(str(imp), {"compress/lossy_quality": 0.25}) + assert written == ["compress/lossy_quality"] + before, after = PRESET_IMPORT.splitlines(), imp.read_text().splitlines() + assert len(before) == len(after) + diffs = [(i, old, new) for i, (old, new) + in enumerate(zip(before, after)) if old != new] + assert diffs == [(16, "compress/lossy_quality=0.7", + "compress/lossy_quality=0.25")] + params = read_import(str(imp))["params"] + assert params["compress/lossy_quality"] == 0.25 + # value formatting matches Godot one-line form + set_import_params(str(imp), {"compress/mode": 2, + "process/fix_alpha_border": False, + "roughness/src_normal": "a,b"}) + params = read_import(str(imp))["params"] + assert params["compress/mode"] == 2 + assert params["process/fix_alpha_border"] is False + assert params["roughness/src_normal"] == "a,b" + text = imp.read_text() + assert "roughness/src_normal=\"a,b\"" in text + assert "; texture preset, keep me" in text + assert '"vram_texture": false' in text # multiline remap dict untouched + + +def test_set_import_params_appends_missing_key(tmp_path): + imp = tmp_path / "hero.png.import" + imp.write_text(PRESET_IMPORT + "\n[other]\n\nx=1\n", encoding="utf-8") + set_import_params(str(imp), {"process/hdr_as_srgb": True}) + # the key lands at the end of [params], ahead of its trailing blank line + assert imp.read_text() == ( + PRESET_IMPORT.rstrip("\n") + "\nprocess/hdr_as_srgb=true\n" + + "\n[other]\n\nx=1\n") + assert read_import(str(imp))["params"]["process/hdr_as_srgb"] is True + + +def test_live_set_preset_survives_reimport(tmp_path): + godot = shutil.which("godot") + if not godot: + pytest.skip("no godot binary on PATH") + assert godot is not None + root = make_project(tmp_path) + asset = os.path.join(root, "hero.png") + with open(asset, "wb") as handle: + import base64 + handle.write(base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")) + first = subprocess.run( + [godot, "--headless", "--path", root, "--import"], + capture_output=True, text=True, timeout=300) + assert first.returncode == 0, first.stderr[-2000:] + import_path = asset + ".import" + assert os.path.isfile(import_path) + assert "params" in read_import(import_path) + set_import_params(import_path, {"compress/lossy_quality": 0.25}) + assert read_import(import_path)["params"]["compress/lossy_quality"] == 0.25 + second = subprocess.run( + [godot, "--headless", "--path", root, "--import"], + capture_output=True, text=True, timeout=300) + assert second.returncode == 0, second.stderr[-2000:] + assert read_import(import_path)["params"]["compress/lossy_quality"] == 0.25 diff --git a/tests/test_scan.py b/tests/test_scan.py new file mode 100644 index 0000000..ce4538d --- /dev/null +++ b/tests/test_scan.py @@ -0,0 +1,132 @@ +"""Project scanner tests: asset discovery, sidecars, exclusions, CLI JSON.""" + +import json +import os + +import pytest + +from assetmgr.cli import main +from assetmgr.scan import scan_project + +SAMPLE_PROJECT_GODOT = """\ +config_version=5 + +[application] + +config/name="Scan Game" +""" + +TEXTURE_IMPORT = """\ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1scanuid" +path="res://.godot/imported/hero.png-abc123.ctex" + +[deps] + +source_file="res://art/hero.png" +dest_files=["res://.godot/imported/hero.png-abc123.ctex"] + +[params] + +compress/mode=0 +process/fix_alpha_border=true +""" + + +def make_project(tmp_path, name="game"): + root = tmp_path / name + (root / "art").mkdir(parents=True) + (root / "project.godot").write_text(SAMPLE_PROJECT_GODOT) + (root / "art" / "hero.png").write_bytes(b"png-bytes") + (root / "main.tscn").write_text("[gd_scene]\n") + return str(root) + + +def by_path(assets): + return {a["path"]: a for a in assets} + + +def test_scan_finds_assets(tmp_path): + root = make_project(tmp_path) + assets = scan_project(root) + assert json.dumps(assets) # JSON-serializable + found = by_path(assets) + assert "res://art/hero.png" in found + assert "res://main.tscn" in found + hero = found["res://art/hero.png"] + assert hero["name"] == "hero.png" + assert hero["suffix"] == ".png" + assert hero["size_bytes"] == len(b"png-bytes") + assert isinstance(hero["mtime"], float) + assert hero["has_import"] is False + assert hero["import"] is None + assert hero["uid"] is None + assert hero["attributes"] == {} + + +def test_scan_detects_import_uid_meta(tmp_path): + root = make_project(tmp_path) + hero = os.path.join(root, "art", "hero.png") + with open(f"{hero}.import", "w", encoding="utf-8") as handle: + handle.write(TEXTURE_IMPORT) + with open(f"{hero}.uid", "w", encoding="utf-8") as handle: + handle.write("uid://b1scanuid\n") + notes = os.path.join(root, "notes.txt") + with open(notes, "w", encoding="utf-8") as handle: + handle.write("hello") + with open(f"{notes}.meta.json", "w", encoding="utf-8") as handle: + json.dump({"tags": ["draft"]}, handle) + + found = by_path(scan_project(root)) + hero_entry = found["res://art/hero.png"] + assert hero_entry["has_import"] is True + assert hero_entry["import"]["importer"] == "texture" + assert hero_entry["import"]["type"] == "CompressedTexture2D" + assert hero_entry["import"]["uid"] == "uid://b1scanuid" + assert hero_entry["import"]["params"]["compress/mode"] == 0 + assert hero_entry["uid"] == "uid://b1scanuid" + assert found["res://notes.txt"]["attributes"] == {"tags": ["draft"]} + + +def test_scan_excludes_godot_cache_and_sidecars(tmp_path): + root = make_project(tmp_path) + cache = os.path.join(root, ".godot", "imported") + os.makedirs(cache) + with open(os.path.join(cache, "hero.png-abc123.ctex"), "w") as handle: + handle.write("compiled") + hero = os.path.join(root, "art", "hero.png") + with open(f"{hero}.import", "w", encoding="utf-8") as handle: + handle.write(TEXTURE_IMPORT) + with open(f"{hero}.uid", "w", encoding="utf-8") as handle: + handle.write("uid://b1scanuid\n") + + paths = [a["path"] for a in scan_project(root)] + assert paths, "expected assets" + assert not any(".godot" in p.removeprefix("res://").split("/") for p in paths) + assert not any(p.endswith((".import", ".uid", ".meta.json")) for p in paths) + assert "res://art/hero.png" in paths + + +def test_scan_requires_godot_project(tmp_path): + with pytest.raises(FileNotFoundError): + scan_project(str(tmp_path)) + with pytest.raises(FileNotFoundError): + scan_project(os.path.join(str(tmp_path), "missing")) + + +def test_project_scan_cli_json(tmp_path, capsys): + root = make_project(tmp_path) + assert main(["project-scan", root, "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["project_name"] == "Scan Game" + assert {a["path"] for a in payload["assets"]} >= { + "res://art/hero.png", "res://main.tscn"} + + +def test_project_scan_cli_json_not_a_project(tmp_path, capsys): + assert main(["project-scan", str(tmp_path), "--json"]) == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["error"] == "not a godot project" diff --git a/tests/test_thumbnails.py b/tests/test_thumbnails.py new file mode 100644 index 0000000..c2daa66 --- /dev/null +++ b/tests/test_thumbnails.py @@ -0,0 +1,55 @@ +"""Thumbnail tests: apsync.generate_thumbnail via ffmpeg / ImageMagick.""" + +import os +import shutil +import struct +import zlib + +import pytest + +import apsync +from assetmgr.errors import DependencyMissingError + +PNG_MAGIC = bytes.fromhex("89504e470d0a1a0a") + + +def _write_png(path, width=64, height=64, rgb=(255, 0, 0)): + """Write a minimal valid PNG with stdlib only (no Pillow dependency).""" + + def chunk(tag, data): + blob = tag + data + return struct.pack(">I", len(data)) + blob + struct.pack(">I", zlib.crc32(blob) & 0xFFFFFFFF) + + raw = b"".join(b"\x00" + bytes(rgb) * width for _ in range(height)) + png = ( + PNG_MAGIC + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + + chunk(b"IEND", b"") + ) + with open(path, "wb") as handle: + handle.write(png) + return path + + +def _engine_available(): + return shutil.which("ffmpeg") is not None or shutil.which("magick") is not None + + +def test_generate_thumbnail_success(tmp_path): + if not _engine_available(): + pytest.skip("neither ffmpeg nor magick on PATH") + source = _write_png(str(tmp_path / "in.png")) + destination = str(tmp_path / "out.png") + assert apsync.generate_thumbnail(source, destination) == destination + assert os.path.isfile(destination) + assert os.path.getsize(destination) > 0 + with open(destination, "rb") as handle: + assert handle.read(8) == PNG_MAGIC + + +def test_generate_thumbnail_missing_dependency(tmp_path, monkeypatch): + source = _write_png(str(tmp_path / "in.png")) + monkeypatch.setenv("PATH", str(tmp_path)) # dir with no engines on it + with pytest.raises(DependencyMissingError): + apsync.generate_thumbnail(source, str(tmp_path / "out.png"))