thumbnails in the catalog: client CLI, CORS, web library browser, docs

This commit is contained in:
BillyOutlast committed 2026-10-25 19:17:06 -09:00
1 parent 745bdc9e73
commit e76623a4f1
8 files changed
+171 -3

No files matched your search

+24
View File
@@ -263,6 +263,21 @@ def _cmd_library_pull(args: argparse.Namespace) -> int:
return 0
def _cmd_library_thumbnail(args: argparse.Namespace) -> int:
from library.client import LibraryError, pull_thumbnail
try:
server, token = _library_endpoint(args)
except SystemExit as exc:
return _die(args, str(exc), 2)
try:
result = pull_thumbnail(server, token, args.asset_id, args.out)
except LibraryError as exc:
return _die(args, str(exc), 1)
_emit(args, f"thumbnail saved to {result['path']}", result)
return 0
def _library_db(args: argparse.Namespace) -> str:
return os.path.abspath(args.db or "library-data/library.db")
@@ -412,6 +427,15 @@ def build_parser() -> argparse.ArgumentParser:
_add_json(p)
p.set_defaults(func=_cmd_library_pull)
p = sub.add_parser("library-thumbnail",
help="download an asset's thumbnail PNG")
p.add_argument("asset_id", type=int)
p.add_argument("--out", required=True)
p.add_argument("--server", default="")
p.add_argument("--token", default="")
_add_json(p)
p.set_defaults(func=_cmd_library_thumbnail)
p = sub.add_parser("library-token-create",
help="create a library API token (printed once)")
p.add_argument("name")
+1
View File
@@ -75,6 +75,7 @@ asset inventory) · `thumbnail SRC [--out PNG]` (exit 3 = no engine) ·
`library-init DIR` · `library-push FILE --server URL --token T [--name N]
[--kind K] [--importer I] [--params JSON] [--tags a,b]` ·
`library-pull ID --out PATH --server URL --token T` ·
`library-thumbnail ID --out PNG --server URL --token T` (exit 1 when absent) ·
`library-token-create NAME [--db PATH]` (prints once) ·
`library-token-list` · `library-token-revoke NAME`
(server/token also via `ASSETMGR_LIBRARY_URL` / `ASSETMGR_LIBRARY_TOKEN`).
+19 -2
View File
@@ -19,9 +19,20 @@ The API is token-authenticated (`Authorization: Bearer <token>`):
- `POST /api/v1/assets?name=&kind=&importer=&params={}&tags=` (raw body)
— publish; replies `{"id", "blob_sha256"}` (dedupe is by content hash)
- `GET /api/v1/assets/<id>/blob` — download bytes
- `GET /api/v1/assets/<id>/thumbnail` — 320px PNG preview, or 404
`{"error": "no thumbnail"}` when the asset has none
Thumbnails render server-side at publish time (either ingest path) via
ffmpeg/ImageMagick when a suitable engine is installed — best effort:
unsupported formats, or a server with no engine at all, simply get no
thumbnail and everything else works. Asset listings carry
`thumbnail_sha256` (null when absent). Databases from older releases
gain the column automatically on first open; existing rows keep serving
with no thumbnail until re-published.
The same operations exist offline-first on the CLI:
`assetmgr library-init|library-push|library-pull` (see `docs/API.md`).
`assetmgr library-init|library-push|library-pull|library-thumbnail`
(see `docs/API.md`).
Uploads are capped at 2 GiB (`MAX_UPLOAD_BYTES`); for anything large use
the session protocol — the CLI switches automatically above 64 MiB
(`--chunk-above BYTES`), or force it with `library-push --chunked
@@ -35,7 +46,13 @@ abandoned sessions are swept on server start.
2. Project → Project Settings → Plugins → enable **Asset Library**.
3. Open the dock (left), enter server URL + token, Search, select, Download.
Files land in `res://<name>` and the editor filesystem rescans, so
`.import` sidecars generate on the spot.
`.import` sidecars generate on the spot. Selecting a result also shows
its server-rendered thumbnail in the preview pane (blank when the
asset has none).
4. The web UI (native binary / Docker) has a Library section that
searches the same catalog over HTTP (the server sends
`Access-Control-Allow-Origin: *`; auth still required) and shows the
thumbnails inline.
Publish the other way with the `godot::publish-to-library` action
(importer + params travel in the upload), or plain
+14 -1
View File
@@ -8,7 +8,8 @@ import urllib.error
import urllib.parse
import urllib.request
__all__ = ["LibraryError", "push", "pull", "upload_status"]
__all__ = ["LibraryError", "push", "pull", "pull_thumbnail",
"upload_status"]
#: Files at or above this size upload through the session protocol.
CHUNK_ABOVE_BYTES = 64 << 20
@@ -96,6 +97,18 @@ def pull(server: str, token: str, asset_id: int, out_path: str) -> dict:
return {"path": os.path.abspath(out_path), "size": len(body)}
def pull_thumbnail(server: str, token: str, asset_id: int,
out_path: str) -> dict:
"""Download an asset's thumbnail PNG; raises LibraryError when absent."""
status, body = _request(server, token, "GET",
f"/api/v1/assets/{asset_id}/thumbnail")
if status != 200:
raise LibraryError(f"no thumbnail: HTTP {status}", status=status)
with open(out_path, "wb") as handle:
handle.write(body)
return {"path": os.path.abspath(out_path), "size": len(body)}
def upload_status(server: str, token: str, upload_id: str) -> dict:
"""Current ``{"upload_id", "offset", "size"}`` for a session."""
status, body = _request(server, token, "GET",
+13
View File
@@ -45,6 +45,19 @@ def _make_handler(lib: "LibraryServer") -> type[BaseHTTPRequestHandler]:
def log_message(self, format: str, *args: object) -> None:
pass # keep test output clean; front with a real server for logs
def end_headers(self) -> None:
# Wide-open CORS: this is a self-hosted LAN tool whose web UI
# lives on another origin (assetmgrd). Auth still required.
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers",
"Authorization, Content-Type")
super().end_headers()
def do_OPTIONS(self) -> None:
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.end_headers()
# -- helpers ----------------------------------------------------
def _json(self, code: int, payload: object) -> None:
body = json.dumps(payload).encode()
+76
View File
@@ -48,6 +48,16 @@ pre { background: #0c0e11; padding: 12px; border-radius: 6px; overflow: auto; }
<table><thead><tr><th>Path</th><th>Size</th><th>Import</th><th>Details</th><th>Preview</th></tr></thead>
<tbody id="scanrows"><tr><td colspan="5" class="muted">No scan yet.</td></tr></tbody></table>
<h2>Library</h2>
<div class="row">
<input id="libserver" placeholder="http://library:8081" size="26">
<input id="libtoken" placeholder="token" size="20" type="password">
<input id="libquery" placeholder="search..." size="20">
<button onclick="searchLibrary()">Search</button>
</div>
<table><thead><tr><th></th><th>ID</th><th>Name</th><th>Kind</th><th>Size</th><th>Tags</th></tr></thead>
<tbody id="librows"><tr><td colspan="6" class="muted">Not connected.</td></tr></tbody></table>
<h2>Status</h2>
<div class="row">
<input id="statuspath" placeholder="/path/to/project" size="34">
@@ -159,6 +169,72 @@ async function showThumb(root, resPath, cell, btn) {
btn.disabled = false;
}
}
function libBase() {
return document.getElementById('libserver').value.replace(/\/+$/, '');
}
function libHeaders() {
return {'Authorization':
'Bearer ' + document.getElementById('libtoken').value};
}
async function searchLibrary() {
const q = document.getElementById('libquery').value;
const tb = document.getElementById('librows');
tb.innerHTML = '';
let r;
try {
r = await fetch(libBase() + '/api/v1/assets' +
(q ? '?q=' + encodeURIComponent(q) : ''), {headers: libHeaders()});
} catch (e) {
tb.innerHTML = '<tr><td colspan="6" class="muted">Cannot reach library ' +
'(check server URL; the library allows cross-origin requests, ' +
'but https UI pages cannot fetch plain-http servers).</td></tr>';
return;
}
if (!r.ok) {
say({http: r.status, ...(await r.json().catch(() => ({})))});
tb.innerHTML = '<tr><td colspan="6" class="muted">Request failed.</td></tr>';
return;
}
const body = await r.json();
for (const a of body.assets) {
const tr = document.createElement('tr');
const thumbTd = document.createElement('td');
if (a.thumbnail_sha256) {
loadLibThumb(a.id, thumbTd);
} else {
thumbTd.innerHTML = '<span class="muted">—</span>';
}
const idTd = document.createElement('td');
idTd.textContent = a.id;
const nameTd = document.createElement('td');
nameTd.textContent = a.name;
const kindTd = document.createElement('td');
kindTd.textContent = a.kind;
const sizeTd = document.createElement('td');
sizeTd.textContent = fmtSize(a.size_bytes);
sizeTd.style.whiteSpace = 'nowrap';
const tagsTd = document.createElement('td');
tagsTd.textContent = (a.tags || []).join(', ');
tr.append(thumbTd, idTd, nameTd, kindTd, sizeTd, tagsTd);
tb.appendChild(tr);
}
if (!body.assets.length) {
tb.innerHTML = '<tr><td colspan="6" class="muted">No assets found.</td></tr>';
}
say({library: libBase(), assets: body.assets.length});
}
async function loadLibThumb(id, cell) {
try {
const r = await fetch(libBase() + '/api/v1/assets/' + id + '/thumbnail',
{headers: libHeaders()});
if (!r.ok) return;
const img = document.createElement('img');
img.style.maxWidth = '96px';
img.loading = 'lazy';
img.src = URL.createObjectURL(await r.blob());
cell.replaceChildren(img);
} catch (e) { /* leave the cell empty */ }
}
function fmtSize(n) {
if (n < 1024) return n + ' B';
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
+23
View File
@@ -194,6 +194,29 @@ def test_library_push_chunked_roundtrip(tmp_path, capsys):
assert open(dest, "rb").read() == b"q" * 100
def test_library_thumbnail_roundtrip(tmp_path, capsys):
import shutil
from test_library_server import TOKEN, start_server
if shutil.which("ffmpeg") is None and shutil.which("magick") is None:
pytest.skip("no thumbnail engine on PATH")
_, base = start_server(tmp_path / "srv")
source = _write_png(tmp_path / "hero.png")
code, created = _run_json(capsys, "library-push", source,
"--server", base, "--token", TOKEN)
assert code == 0
out = str(tmp_path / "thumb.png")
code, thumb = _run_json(capsys, "library-thumbnail",
str(created["id"]), "--out", out,
"--server", base, "--token", TOKEN)
assert code == 0
with open(out, "rb") as handle:
assert handle.read(8) == b"\x89PNG\r\n\x1a\n"
code, missing = _run_json(capsys, "library-thumbnail", "999",
"--out", out, "--server", base,
"--token", TOKEN)
assert code == 1 and "404" in missing["error"]
def test_library_token_lifecycle_against_server(tmp_path, capsys):
from test_library_server import start_server
db = str(tmp_path / "srv" / "lib.db")
+1
View File
@@ -104,6 +104,7 @@ def test_image_upload_generates_thumbnail(tmp_path):
status, headers, blob = _get(base, f"/api/v1/assets/{asset_id}/thumbnail")
assert status == 200, blob[:100]
assert headers.get("Content-Type") == "image/png"
assert headers.get("Access-Control-Allow-Origin") == "*"
assert blob[:8] == b"\x89PNG\r\n\x1a\n"