mirror of
https://github.com/Heretek-AI/Asset-Manager.git
synced 2026-11-07 10:45:57 -10:00
All three Godot phases: preset write path, project scanner + browser UI, container ffmpeg
This commit is contained in:
1 parent
eebd9ddfac
commit
75a6dba73d
13 files changed
+797
-3
No files matched your search
+1
-1
@@ -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 \
|
||||
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
+1
-1
@@ -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]}")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -39,6 +40,14 @@ pre { background: #0c0e11; padding: 12px; border-radius: 6px; overflow: auto; }
|
||||
<button onclick="initProject()">Init project</button>
|
||||
</div>
|
||||
|
||||
<h2>Project browser</h2>
|
||||
<div class="row">
|
||||
<input id="scanpath" placeholder="/path/to/godot/project" size="34">
|
||||
<button onclick="scanProject()">Scan</button>
|
||||
</div>
|
||||
<table><thead><tr><th>Path</th><th>Size</th><th>Import</th><th>Details</th></tr></thead>
|
||||
<tbody id="scanrows"><tr><td colspan="4" class="muted">No scan yet.</td></tr></tbody></table>
|
||||
|
||||
<h2>Status</h2>
|
||||
<div class="row">
|
||||
<input id="statuspath" placeholder="/path/to/project" size="34">
|
||||
@@ -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 = '<span class="muted">raw</span>';
|
||||
}
|
||||
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 = '<tr><td colspan="4" class="muted">No assets found.</td></tr>';
|
||||
}
|
||||
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(() => {});
|
||||
|
||||
+90
-1
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"))
|
||||
Reference in new issue
Block a user