mirror of
https://github.com/Heretek-AI/Asset-Manager.git
synced 2026-11-07 10:45:57 -10:00
thumbnails in the catalog: client CLI, CORS, web library browser, docs
This commit is contained in:
1 parent
745bdc9e73
commit
e76623a4f1
8 files changed
+171
-3
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
@@ -19,9 +19,20 @@ The API is token-authenticated (`Authorization: Bearer <token>`):
|
||||
- `POST /api/v1/assets?name=&kind=&importer=¶ms={}&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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user