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; } +| Path | Size | Import | Details |
|---|---|---|---|
| No scan yet. | |||