From 977c9b6c15620b60235e556a1859325c5111d697 Mon Sep 17 00:00:00 2001 From: Moshu <1709219+DatMoshu@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:52:17 -0500 Subject: [PATCH] Export a uo-content job as a transfer artifact (SM-XPORT1) tools/transfer-export/run.py reads a finished job (read only), crops each rendered frame to its alpha bounding box, writes transfer.json and the cropped PNGs, then reads the result back with spritemotion.transfer.read. Fields the job does not record are left out. Docs gain "Making one". Co-Authored-By: Claude Sonnet 5.5 --- docs/transfer-artifact.md | 36 ++++- tests/unit/test_transfer_export.py | 218 +++++++++++++++++++++++++++ tools/transfer-export/run.py | 227 +++++++++++++++++++++++++++++ tools/uo-content/README.md | 3 + 4 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_transfer_export.py create mode 100644 tools/transfer-export/run.py diff --git a/docs/transfer-artifact.md b/docs/transfer-artifact.md index 25553b4..2779c6b 100644 --- a/docs/transfer-artifact.md +++ b/docs/transfer-artifact.md @@ -6,8 +6,8 @@ The contract comes from the "Transfer artifact" and "Pixel contract" paragraphs [guo-integration-plan.md](guo-integration-plan.md). This page covers the format and the reader. The schema is [`common/schemas/transfer-artifact.schema.json`](../common/schemas/transfer-artifact.schema.json) -(kind `spritemotion.transfer-artifact`, `schema_version` 1). **Status: draft for GUO review.** Nothing writes -this format yet: the exporter that produces it from a `tools/uo-content` build job is the next story. +(kind `spritemotion.transfer-artifact`, `schema_version` 1). **Status: draft for GUO review.** The exporter that +writes it from a `tools/uo-content` build job is described under [Making one](#making-one). ## What is in the manifest @@ -64,6 +64,38 @@ be equal; the reader raises `TransferError` when they differ. on an action is how fast to play it. They are separate on purpose. `frame_delay_ms` is metadata only: the UO client fixes the timing. +## Making one + +A finished `tools/uo-content` job (status `complete`) becomes a transfer artifact with: + +```powershell +python tools/transfer-export/run.py --job --out +``` + +It needs Pillow (`pip install "spritemotion[imaging]"`); the reader does not. The job folder is only read. `--out` +must be a new or empty folder outside the job; the exporter refuses anything else, and a failed export removes the +folder it created. + +For every stored frame in `render/clothing` it crops the PNG to its alpha bounding box on the 256 x 256 canvas and +writes `frames/aNN-dD-fI.png`; a fully transparent frame becomes an `empty` frame with no file. It then writes +`transfer.json` and finishes by calling `spritemotion.transfer.read` on the result: if that fails, the export failed. + +What the manifest takes from the job, and what it leaves out: + +| Manifest field | From | Notes | +|---|---|---| +| `identity` | `job.json` | item id and slot from `fit_item` (else `name` and `part`), `source_job` is the job folder name, `project_id` is `--project-id` (default `spritemotion`). No `body_profile`: the job does not record one | +| `reproducibility` | `job.json` | `backend_sha256` as `model_fingerprint`, `render_fingerprint`, `asset_sha256` and `source_fingerprints` as `input_hashes` (file names only, no paths). The frozen `fit_adjustments` are written to `fit-adjustments.json` as `fit` and `fit_hash`; absent when the job has none. No `tool_versions` | +| `animation` | `render/clothing/meta.json`, `job.json` | actions, stored directions and frame counts from the rendered blocks; `coverage` is the job's `mode` (`preview` or `full`; a `full` job missing a stored direction is refused); `sampling` is the UO rule, scene frame 1 + 3i. No `playback`: the job records no timing | +| `pixels` | the exported PNGs | `alpha` is `binary` only if every exported alpha is 0 or 255, otherwise `straight`; `quantization` is `none` | +| `equipment` | `job.json` | only a note naming the uo-content part. No `layer` or `tiledata`: the job records neither | +| `acceptance` | `validation.json` | `manual_review` is `none`; the report is copied in as `validation.json`; `known_failures` lists clipped and empty frames the report names | +| `provenance` | `job.json` | source (input file names, input kind) and rendered output (the job, its creation method) are separate. The job records no licence, so both redistribution classes are `unknown` unless you pass `--source-redistribution`, `--rendered-redistribution`, `--source-license` or `--rendered-license` | + +Not done by the exporter: palette quantization, VD decoding, and anything GUO-side. + +Job renders can include client-derived art, so an export of one is as private as the job. Do not commit or share it. + ## Reading it `spritemotion.transfer` uses the standard library only (no numpy or Pillow), so it works from a plain diff --git a/tests/unit/test_transfer_export.py b/tests/unit/test_transfer_export.py new file mode 100644 index 0000000..62f70bc --- /dev/null +++ b/tests/unit/test_transfer_export.py @@ -0,0 +1,218 @@ +"""tools/transfer-export: a synthetic tools/uo-content job folder becomes a transfer artifact the reader accepts. + +No game data and no real renders: the job is built here from a few synthetic sprites. +""" +import hashlib +import importlib.util +import json +import struct +import zlib +from pathlib import Path + +import pytest + +from spritemotion import transfer + +PIL = pytest.importorskip("PIL") + +REPO = Path(__file__).resolve().parents[2] + + +def _load(name, relative): + spec = importlib.util.spec_from_file_location(name, REPO / relative) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +exporter = _load("transfer_export", "tools/transfer-export/run.py") +fixture = _load("transfer_fixture_for_export", "tools/transfer-fixture/run.py") + + +def canvas_png(pixels): + """A 256x256 RGBA PNG (stdlib only, compressed) with the given {(x, y): (r, g, b, a)} pixels, rest transparent.""" + rows = [] + for y in range(256): + row = bytearray(256 * 4) + for (px, py), colour in pixels.items(): + if py == y: + row[px * 4:px * 4 + 4] = bytes(colour) + rows.append(b"\x00" + bytes(row)) + header = struct.pack(">IIBBBBB", 256, 256, 8, 6, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + fixture.chunk(b"IHDR", header) + + fixture.chunk(b"IDAT", zlib.compress(b"".join(rows), 9)) + fixture.chunk(b"IEND", b"")) + + +def sprite(left, top, width, height, alpha=255): + """An L-shaped, asymmetric sprite: the left stem and the top arm, plus one bright corner pixel.""" + pixels = {} + for x in range(left, left + width): + for y in range(top, top + height): + if x < left + 3 or y < top + 3: + pixels[(x, y)] = (200, 60, 40, alpha) + pixels[(left + width - 1, top + height - 1)] = (255, 240, 0, 255) + return pixels + + +def make_job(root: Path, blocks, *, mode="preview", actions=None, extra=None, validation=None, state="complete"): + """blocks: {(action, direction): [pixels-dict or None per frame]}. Layout matches tools/uo-content/pipeline.py.""" + job = root / "0123456789ab" + render = job / "render/clothing" + meta_blocks = [] + for (action, direction), frames in sorted(blocks.items()): + name = f"{action:02d}_synthetic_{action}" + files = [] + for index, pixels in enumerate(frames): + folder = render / "frames" / name / f"dir{direction}" + folder.mkdir(parents=True, exist_ok=True) + (folder / f"{index:02d}.png").write_bytes(canvas_png(pixels or {})) + files.append({"file": f"{index:02d}.png"}) + meta_blocks.append({"action": action, "dir": direction, "name": name, "frames": files}) + (render / "meta.json").write_text(json.dumps( + {"tool": "render_uo_layer", "anim_type": 2, "actions": 35, "mode": "canvas", "canvas": [256, 256], + "anchor": [128, 192], "blocks": meta_blocks}), encoding="utf-8") + spec = {"name": "Synthetic Helm", "part": "helm", "mode": mode, "actions": actions or sorted({a for a, _ in blocks}), + "input_kind": "model", "creation_method": "imported mesh", "asset": "C:\\elsewhere\\input\\helm.glb", + "asset_sha256": "a" * 64, "backend_sha256": "b" * 64, "render_fingerprint": "c" * 64, + "source_files": ["C:\\elsewhere\\packs\\helm.glb"], "source_fingerprints": {"C:\\elsewhere\\packs\\helm.glb": "d" * 64}} + spec.update(extra or {}) + (job / "job.json").write_text(json.dumps(spec), encoding="utf-8") + (job / "validation.json").write_text(json.dumps(validation or { + "frames": sum(len(f) for f in blocks.values()), "blocks": len(blocks), "vd_alpha_and_anchor_roundtrip": True, + "full_animation_set": mode == "full", "clipped_frames": [], "empty_frames": [], "deployed": False}), encoding="utf-8") + (job / "status.json").write_text(json.dumps({"state": state, "name": "Synthetic Helm", "mode": mode}), encoding="utf-8") + return job + + +def tree_hashes(folder: Path): + return {str(p.relative_to(folder)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(folder.rglob("*")) if p.is_file()} + + +@pytest.fixture +def preview_job(tmp_path): + blocks = {(a, d): [sprite(100 + 4 * d, 90, 20 + d, 30), sprite(104, 100, 12, 40)] + for a in (0, 4) for d in range(5)} + blocks[(4, 2)][1] = None # an all-transparent frame + blocks[(4, 4)][0] = sprite(180, 60, 30, 60) # right of the anchor: positive crop, negative centre x + return make_job(tmp_path, blocks) + + +def test_export_reads_back_and_centres_match_the_crops(preview_job, tmp_path): + out = exporter.export(preview_job, tmp_path / "out") + artifact = transfer.read(out) + assert artifact.actions == [0, 4] + assert artifact.manifest["animation"]["coverage"] == "preview" + assert artifact.manifest["animation"]["sampling"] == {"first_scene_frame": 1, "scene_frame_step": 3} + frame = artifact.frame(0, 0, 0) + assert (frame.crop.left, frame.crop.top, frame.crop.right, frame.crop.bottom) == (100, 90, 120, 120) + for f in artifact.frames: + if not f.empty: + assert f.centre == (128 - f.crop.left, 192 - f.crop.bottom) + assert transfer.png_size(f.path) == (f.crop.width, f.crop.height) + assert artifact.frame(4, 4, 0).centre == (128 - 180, 192 - 120) + assert artifact.resolve(4, 7, 0).mirrored # direction 7 reads stored direction 1 + + +def test_all_transparent_frame_becomes_empty(preview_job, tmp_path): + artifact = transfer.read(exporter.export(preview_job, tmp_path / "out")) + empty = artifact.frame(4, 2, 1) + assert empty.empty and empty.path is None and empty.crop is None + entry = next(f for f in artifact.manifest["frames"] if (f["action"], f["direction"], f["index"]) == (4, 2, 1)) + assert entry == {"action": 4, "direction": 2, "index": 1, "empty": True} + assert not (tmp_path / "out/frames/a04-d2-f1.png").exists() + + +def test_manifest_records_what_the_job_records_and_nothing_else(preview_job, tmp_path): + manifest = transfer.read(exporter.export(preview_job, tmp_path / "out")).manifest + assert manifest["identity"] == {"project_id": "spritemotion", "item_id": "Synthetic-Helm", "slot": "helm", + "source_job": "0123456789ab"} + assert manifest["reproducibility"] == {"model_fingerprint": "b" * 64, "renderer_fingerprint": "c" * 64, + "input_hashes": {"asset": "a" * 64, "source/helm.glb": "d" * 64}} + assert manifest["pixels"]["alpha"] == "binary" and manifest["pixels"]["quantization"] == {"policy": "none"} + assert "playback" not in manifest["animation"]["actions"][0] + assert manifest["provenance"]["source"] == {"redistribution": "unknown", "origin": "helm.glb", "notes": "input kind: model"} + assert manifest["provenance"]["rendered"]["origin"] == "tools/uo-content job 0123456789ab" + assert manifest["acceptance"]["manual_review"] == "none" + assert "C:" not in json.dumps(manifest) and "elsewhere" not in json.dumps(manifest) + + +def test_job_folder_is_never_written(preview_job, tmp_path): + before = tree_hashes(preview_job) + exporter.export(preview_job, tmp_path / "out") + assert tree_hashes(preview_job) == before + + +def test_out_reuse_is_refused_and_a_failed_export_leaves_nothing(preview_job, tmp_path): + out = exporter.export(preview_job, tmp_path / "out") + kept = tree_hashes(out) + with pytest.raises(exporter.ExportError, match="not empty"): + exporter.export(preview_job, out) + assert tree_hashes(out) == kept + with pytest.raises(exporter.ExportError, match="outside the job folder"): + exporter.export(preview_job, preview_job / "transfer") + assert not (preview_job / "transfer").exists() + (preview_job / "status.json").write_text(json.dumps({"state": "building"}), encoding="utf-8") + with pytest.raises(exporter.ExportError, match="not complete"): + exporter.export(preview_job, tmp_path / "unfinished") + assert not (tmp_path / "unfinished").exists() + + +def test_empty_existing_out_is_accepted(preview_job, tmp_path): + (tmp_path / "empty").mkdir() + assert transfer.read(exporter.export(preview_job, tmp_path / "empty")).actions == [0, 4] + + +def test_partial_alpha_makes_the_alpha_convention_straight(tmp_path): + job = make_job(tmp_path, {(0, d): [sprite(100, 90, 20, 30, alpha=128)] for d in range(5)}) + assert transfer.read(exporter.export(job, tmp_path / "out")).manifest["pixels"]["alpha"] == "straight" + + +def test_full_coverage_needs_every_stored_direction(tmp_path): + blocks = {(a, d): [{(128, 150): (10, 200, 10, 255)}] for a in range(35) for d in range(5)} + job = make_job(tmp_path / "full", blocks, mode="full") + artifact = transfer.read(exporter.export(job, tmp_path / "full-out")) + assert artifact.manifest["animation"]["coverage"] == "full" + assert len(artifact.actions) == 35 and len(artifact.frames) == 175 + short = make_job(tmp_path / "short", {(0, d): [{(128, 150): (1, 2, 3, 255)}] for d in range(4)}, mode="full") + with pytest.raises(exporter.ExportError, match="missing stored directions"): + exporter.export(short, tmp_path / "short-out") + assert not (tmp_path / "short-out").exists() + + +def test_fit_snapshot_and_validation_report_are_exported_with_hashes(tmp_path): + fit = {"parts": {"helm": {"scale": 1.1}}, "items": {}} + validation = {"frames": 5, "blocks": 5, "vd_alpha_and_anchor_roundtrip": True, "clipped_frames": [[0, 1, 0]], + "empty_frames": [[0, 2, 0]]} + job = make_job(tmp_path, {(0, d): [sprite(100, 90, 20, 30)] for d in range(5)}, extra={"fit_adjustments": fit}, + validation=validation) + out = exporter.export(job, tmp_path / "out") + manifest = transfer.read(out).manifest + fit_ref = manifest["reproducibility"]["fit"] + assert json.loads((out / fit_ref["path"]).read_text(encoding="utf-8")) == fit + assert manifest["reproducibility"]["fit_hash"] == fit_ref["sha256"] + assert manifest["acceptance"]["known_failures"] == ["clipped frame action 0 direction 1 index 0", + "empty frame action 0 direction 2 index 0"] + assert json.loads((out / "validation.json").read_text(encoding="utf-8")) == validation + + +def test_job_without_a_fit_snapshot_leaves_the_fit_fields_out(preview_job, tmp_path): + reproducibility = transfer.read(exporter.export(preview_job, tmp_path / "out")).manifest["reproducibility"] + assert "fit" not in reproducibility and "fit_hash" not in reproducibility + + +def test_wrong_canvas_size_is_refused(tmp_path): + job = make_job(tmp_path, {(0, d): [{}] for d in range(5)}) + meta = job / "render/clothing/meta.json" + data = json.loads(meta.read_text(encoding="utf-8")) + data["canvas"] = [136, 120] + meta.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(exporter.ExportError, match="canvas"): + exporter.export(job, tmp_path / "out") + + +def test_cli_exit_codes(preview_job, tmp_path, capsys): + assert exporter.main(["--job", str(preview_job), "--out", str(tmp_path / "out"), "--source-redistribution", "private", + "--source-license", "test-licence"]) == 0 + assert transfer.read(tmp_path / "out").manifest["provenance"]["source"]["license"] == "test-licence" + assert exporter.main(["--job", str(preview_job), "--out", str(tmp_path / "out")]) == 1 + assert "Export failed" in capsys.readouterr().err diff --git a/tools/transfer-export/run.py b/tools/transfer-export/run.py new file mode 100644 index 0000000..0fb14f8 --- /dev/null +++ b/tools/transfer-export/run.py @@ -0,0 +1,227 @@ +"""Export a finished tools/uo-content build job as a transfer artifact. + + python tools/transfer-export/run.py --job --out + +Reads the job's render/clothing PNGs and canvas metadata, job.json, validation.json and input provenance (read only), +writes /transfer.json plus one cropped PNG per stored frame, then reads the result back with +spritemotion.transfer.read. Fields the job does not record are left out. Needs Pillow (the `imaging` extra); the +reader does not. +""" +import argparse +import hashlib +import json +import re +import shutil +import sys +from pathlib import Path + +from spritemotion import transfer + +SAMPLING = {"first_scene_frame": 1, "scene_frame_step": 3} # UO frame i = scene frame 1 + 3i +CANVAS = [256, 256] +ANCHOR = [128, 192] +FIT_FILE = "fit-adjustments.json" +VALIDATION_FILE = "validation.json" +REDISTRIBUTION = ("public", "private", "restricted", "unknown") + + +class ExportError(Exception): + """The job cannot be exported; the message says why.""" + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _read_job_json(job: Path, name: str): + path = job / name + if not path.is_file(): + raise ExportError(f"{name} is missing from the job folder {job.name}.") + try: + return json.loads(path.read_text(encoding="utf-8")) + except ValueError as error: + raise ExportError(f"{name} is not valid JSON: {error}") from error + + +def _slug(text: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-") or "item" + + +def _action_name(block_name: str) -> str: + return re.sub(r"^\d+_", "", block_name) + + +def _crop_frame(Image, path: Path): + """(cropped RGBA image or None when fully transparent, crop box on the canvas).""" + if not path.is_file(): + raise ExportError(f"Rendered frame is missing: {path.name} ({path.parent.parent.name}/{path.parent.name}).") + image = Image.open(path).convert("RGBA") + if list(image.size) != CANVAS: + raise ExportError(f"{path.parent.parent.name}/{path.parent.name}/{path.name} is {image.size[0]}x{image.size[1]}, " + f"not {CANVAS[0]}x{CANVAS[1]}.") + box = image.getchannel("A").getbbox() + return (None, None) if box is None else (image.crop(box), box) + + +def _png_bytes(image) -> bytes: + import io + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _provenance_side(redistribution, license_, origin, notes): + side = {"redistribution": redistribution} + for key, value in (("license", license_), ("origin", origin), ("notes", notes)): + if value: + side[key] = value + return side + + +def export(job: Path, out: Path, *, project_id="spritemotion", source_redistribution="unknown", source_license=None, + rendered_redistribution="unknown", rendered_license=None) -> Path: + try: + from PIL import Image + except ImportError: + raise ExportError("Pillow is required to export: pip install 'spritemotion[imaging]'.") from None + job, out = Path(job).resolve(), Path(out).resolve() + if not job.is_dir(): + raise ExportError(f"Job folder not found: {job}.") + if out == job or job in out.parents or out in job.parents: + raise ExportError("--out must be a separate folder, outside the job folder (the job is never written to).") + if out.exists() and (not out.is_dir() or any(out.iterdir())): + raise ExportError(f"--out {out} already exists and is not empty; choose a new folder.") + + spec = _read_job_json(job, "job.json") + validation = _read_job_json(job, VALIDATION_FILE) + status = job / "status.json" + if status.is_file() and _read_job_json(job, "status.json").get("state") != "complete": + raise ExportError("The job is not complete (status.json); export a finished job.") + meta = _read_job_json(job, "render/clothing/meta.json") + if meta.get("canvas") != CANVAS or meta.get("anchor") != ANCHOR: + raise ExportError(f"meta.json canvas/anchor are {meta.get('canvas')}/{meta.get('anchor')}, expected {CANVAS}/{ANCHOR}.") + mode = spec.get("mode") + if mode not in ("preview", "full"): + raise ExportError(f"job.json mode must be preview or full, found {mode!r}.") + + blocks = [b for b in meta.get("blocks", []) if b.get("frames")] + if not blocks: + raise ExportError("The job rendered no frames.") + render = job / "render/clothing/frames" + created = not out.exists() + out.mkdir(parents=True, exist_ok=True) + try: + frames, actions, alpha_binary = [], {}, True + for block in sorted(blocks, key=lambda b: (b["action"], b["dir"])): + action, direction = block["action"], block["dir"] + if direction not in range(5): + raise ExportError(f"Block action {action} has stored direction {direction}; only 0-4 are stored.") + entry = actions.setdefault(action, {"action": action, "name": _action_name(block.get("name", "")), + "frame_count": len(block["frames"]), "directions": []}) + if entry["frame_count"] != len(block["frames"]): + raise ExportError(f"Action {action}: directions differ in frame count " + f"({entry['frame_count']} vs {len(block['frames'])} at direction {direction}).") + entry["directions"].append(direction) + for index, item in enumerate(block["frames"]): + image, box = _crop_frame(Image, render / block["name"] / f"dir{direction}" / item["file"]) + record = {"action": action, "direction": direction, "index": index, "empty": image is None} + if image is not None: + alpha_binary = alpha_binary and {value for _, value in image.getchannel("A").getcolors(256)} <= {0, 255} + data = _png_bytes(image) + name = f"frames/a{action:02d}-d{direction}-f{index}.png" + (out / "frames").mkdir(exist_ok=True) + (out / name).write_bytes(data) + left, top, right, bottom = box + record.update(png=name, sha256=_sha256(data), + crop={"left": left, "top": top, "right": right, "bottom": bottom}, + centre={"x": ANCHOR[0] - left, "y": ANCHOR[1] - bottom}) + frames.append(record) + if mode == "full" and any(e["directions"] != [0, 1, 2, 3, 4] for e in actions.values()): + raise ExportError("The job's mode is full but an action is missing stored directions; rebuild it.") + + reproducibility = {} + for key, value in (("model_fingerprint", spec.get("backend_sha256")), + ("renderer_fingerprint", spec.get("render_fingerprint"))): + if value: + reproducibility[key] = value + hashes = {} + if spec.get("asset_sha256"): + hashes["asset"] = spec["asset_sha256"] + for source, digest in (spec.get("source_fingerprints") or {}).items(): + hashes[f"source/{Path(source.replace(chr(92), '/')).name}"] = digest + if hashes: + reproducibility["input_hashes"] = hashes + if "fit_adjustments" in spec: + data = (json.dumps(spec["fit_adjustments"], indent=1) + "\n").encode("utf-8") + (out / FIT_FILE).write_bytes(data) + reproducibility["fit"] = {"path": FIT_FILE, "sha256": _sha256(data)} + reproducibility["fit_hash"] = _sha256(data) + + report = (json.dumps(validation, indent=1) + "\n").encode("utf-8") + (out / VALIDATION_FILE).write_bytes(report) + failures = [f"clipped frame action {a} direction {d} index {i}" for a, d, i in validation.get("clipped_frames", [])] + failures += [f"empty frame action {a} direction {d} index {i}" for a, d, i in validation.get("empty_frames", [])] + if validation.get("vd_alpha_and_anchor_roundtrip") is False: + failures.append("VD alpha/anchor round trip failed") + + identity = {"project_id": project_id, "item_id": _slug(str((spec.get("fit_item") or {}).get("id") or spec.get("name") or "item")), + "slot": (spec.get("fit_item") or {}).get("slot") or spec.get("part") or "unknown", + "source_job": job.name} + source_names = [Path(p.replace(chr(92), "/")).name for p in spec.get("source_files", [])] + if spec.get("asset"): + source_names.append(Path(spec["asset"].replace(chr(92), "/")).name) + source_origin = ", ".join(dict.fromkeys(source_names)) or None + manifest = { + "schema": transfer.KIND, "schema_version": 1, + "identity": identity, + "reproducibility": reproducibility, + "animation": {"mirror_map": {"5": 3, "6": 2, "7": 1}, "coverage": mode, "sampling": dict(SAMPLING), + "actions": [actions[a] for a in sorted(actions)]}, + "pixels": {"canvas": {"width": 256, "height": 256}, "anchor": {"x": 128, "y": 192}, + "alpha": "binary" if alpha_binary else "straight", "quantization": {"policy": "none"}}, + "frames": frames, + "equipment": {"notes": f"uo-content part: {spec['part']}"} if spec.get("part") else {}, + "acceptance": {"manual_review": "none", "validation_report": {"path": VALIDATION_FILE, "sha256": _sha256(report)}, + "known_failures": failures}, + "provenance": { + "source": _provenance_side(source_redistribution, source_license, source_origin, + f"input kind: {spec['input_kind']}" if spec.get("input_kind") else None), + "rendered": _provenance_side(rendered_redistribution, rendered_license, + f"tools/uo-content job {job.name}", spec.get("creation_method")), + }, + } + (out / transfer.MANIFEST).write_bytes((json.dumps(manifest, indent=1) + "\n").encode("utf-8")) + try: + transfer.read(out) + except transfer.TransferError as error: + raise ExportError(f"The export does not read back as a valid transfer artifact: {error}") from error + except Exception: + if created: + shutil.rmtree(out, ignore_errors=True) + raise + return out + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--job", type=Path, required=True, help="A finished tools/uo-content job folder (read only).") + parser.add_argument("--out", type=Path, required=True, help="A new or empty folder to write the artifact to.") + parser.add_argument("--project-id", default="spritemotion") + for side in ("source", "rendered"): + parser.add_argument(f"--{side}-redistribution", choices=REDISTRIBUTION, default="unknown", + help=f"Redistribution class of the {side} side (default unknown: the job does not record it).") + parser.add_argument(f"--{side}-license", default=None) + args = parser.parse_args(argv) + try: + out = export(args.job, args.out, project_id=args.project_id, + source_redistribution=args.source_redistribution, source_license=args.source_license, + rendered_redistribution=args.rendered_redistribution, rendered_license=args.rendered_license) + except ExportError as error: + print(f"Export failed: {error}", file=sys.stderr) + return 1 + print(f"Wrote {sum(1 for p in out.rglob('*') if p.is_file())} files to {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/uo-content/README.md b/tools/uo-content/README.md index b39bd3e..4846e3b 100644 --- a/tools/uo-content/README.md +++ b/tools/uo-content/README.md @@ -86,4 +86,7 @@ python -m unittest discover -s tools/uo-content -p test_content.py The example settings document shows the minimum fields. Optional `rotate_x/y/z` are degrees; `offset_x/y/z` are metres, `scale` is a uniform multiplier, `fit` is `auto` or `preserve`, `mode` is `preview` or `full`. Every job stores its resolved settings, the original prompt and input hash. A custom generator can hand off a model plus these settings without changing the renderer. +To hand a finished job to another tool (GUO's importer) without its folder layout, export it as a transfer artifact: +`python tools/transfer-export/run.py --job --out `. See [Making one](../../docs/transfer-artifact.md#making-one). + Clipping and empty frames are reported, not hidden. Cloth template binding is included; running a fresh physics cloth bake is not automated here. Mounted occlusion uses the source project's horse proxy/masks and needs visual review. The source file was saved by a later Blender 4.2 patch than the local 4.2.0 test runtime; use the author's version when investigating discrepancies.