All three Godot phases: preset write path, project scanner + browser UI, container ffmpeg

This commit is contained in:
BillyOutlast committed 2026-10-25 16:21:58 -09:00
1 parent eebd9ddfac
commit 75a6dba73d
13 files changed
+797 -3

No files matched your search

+1 -1
View File
@@ -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 \
+93
View File
@@ -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)")
+18
View File
@@ -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
View File
@@ -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]}")
+28
View File
@@ -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")
+139
View File
@@ -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", {})
+105
View File
@@ -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
+18
View File
@@ -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)
+66
View File
@@ -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)
}
})
}
}
+51
View File
@@ -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
View File
@@ -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
+132
View File
@@ -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"
+55
View File
@@ -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"))