motionloom 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -1
- package/README.md +78 -11
- package/ROADMAP.md +8 -5
- package/SKILL.md +24 -5
- package/agent-card.json +31 -5
- package/agent-surfaces.json +1 -1
- package/artifact-adapter-registry.json +53 -0
- package/bin/motionloom.mjs +29 -5
- package/docs/CHECKLIST.md +16 -0
- package/docs/STATUS.md +2 -2
- package/docs/audits/data/deep-stress-latest.json +65 -65
- package/docs/releases/2.4.0.md +31 -0
- package/docs/releases/npm-publish-from-workstation.md +19 -8
- package/docs/research/agent-skill-ecosystem-notes.md +47 -0
- package/docs/research/ai-animation-tools-2026-notes.md +81 -0
- package/docs/research/ai-animation-tools-2026-report.md +168 -0
- package/docs/research/ai-pilot-ingest-notes.md +29 -0
- package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
- package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
- package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
- package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
- package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
- package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
- package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
- package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
- package/examples/agent-consumer/rive-package-gate/README.md +20 -0
- package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
- package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
- package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
- package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
- package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
- package/examples/agent-consumer/runtime-pilot/export.json +17 -0
- package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
- package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
- package/package.json +56 -4
- package/references/browser-review-contract.md +7 -1
- package/references/intelligence-core.md +22 -1
- package/rig-adapter-registry.json +39 -0
- package/schemas/action-set.schema.json +42 -0
- package/schemas/artifact-adapter-registry.schema.json +16 -0
- package/schemas/asset-identity.schema.json +117 -0
- package/schemas/asset-provenance.schema.json +1 -1
- package/schemas/atlas-contract.schema.json +48 -0
- package/schemas/control-track.schema.json +18 -0
- package/schemas/export-manifest.schema.json +19 -0
- package/schemas/frame-geometry.schema.json +79 -0
- package/schemas/generation-receipt.schema.json +20 -0
- package/schemas/layered-map.schema.json +75 -0
- package/schemas/rig-adapter-registry.schema.json +14 -0
- package/schemas/rig-compatibility.schema.json +53 -0
- package/schemas/rive-package-manifest.schema.json +43 -0
- package/schemas/runtime-candidate.schema.json +48 -0
- package/schemas/scene-manifest.schema.json +18 -0
- package/scripts/artifact-intake.py +408 -0
- package/scripts/asset-consistency.py +517 -0
- package/scripts/asset-provenance.py +8 -3
- package/scripts/build-ai-pilot.py +504 -0
- package/scripts/isolate-alpha-background.py +147 -0
- package/scripts/quality-gate.py +88 -2
- package/scripts/report.py +85 -0
- package/scripts/resolve-task-bundle.py +84 -0
- package/scripts/review-hook.py +26 -4
- package/scripts/rig-compatibility.py +251 -0
- package/scripts/rive-package-gate.py +245 -0
- package/scripts/runtime-candidate.py +301 -0
- package/scripts/setup.mjs +54 -26
- package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
- package/src/output/runtime-pilot-framer/browser-review.json +20 -0
- package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
- package/src/output/runtime-pilot-framer/manifest.json +31 -0
- package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
- package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
- package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
- package/src/output/runtime-pilot-framer/scene.jsx +52 -0
- package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
- package/tests/scripts/run_tests.py +107 -0
- package/tests/scripts/test_ai_pilot_builder.py +128 -0
- package/tests/scripts/test_alpha_isolation.py +60 -0
- package/tests/scripts/test_artifact_intake.py +146 -0
- package/tests/scripts/test_asset_consistency.py +199 -0
- package/tests/scripts/test_rig_compatibility.py +105 -0
- package/tests/scripts/test_rive_package_gate.py +101 -0
- package/tests/scripts/test_runtime_candidate.py +95 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build a hash-bound, review-only MotionLoom AI scout pilot from four PNG frames.
|
|
3
|
+
|
|
4
|
+
The builder is intentionally pilot-specific. It copies source bytes into a local
|
|
5
|
+
``.motionloom`` workspace, measures their actual alpha geometry using the same
|
|
6
|
+
deterministic reader as Asset Consistency, and writes the contracts that feed
|
|
7
|
+
Artifact Intake and Runtime Candidate. It never manufactures a human approval,
|
|
8
|
+
artist authority, or runtime-pass claim.
|
|
9
|
+
|
|
10
|
+
It uses Python's standard library only and runs on Ubuntu, macOS, and Windows.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import hashlib
|
|
17
|
+
import importlib.util
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
27
|
+
ASSET_ID = "character.scout-robot.ai-pilot"
|
|
28
|
+
PILOT_ID = "ai-pilot-scout-walk"
|
|
29
|
+
FRAME_ORDER = (
|
|
30
|
+
("idle", "idle", "preview"),
|
|
31
|
+
("contact-right", "contact_right", "runtime_candidate"),
|
|
32
|
+
("passing", "passing", "runtime_candidate"),
|
|
33
|
+
("contact-left", "contact_left", "runtime_candidate"),
|
|
34
|
+
)
|
|
35
|
+
PROVIDER_PROFILES = {
|
|
36
|
+
"internal-imagegen": {
|
|
37
|
+
"adapter_id": "internal.imagegen",
|
|
38
|
+
"kind": "internal_skill",
|
|
39
|
+
"invocation_mode": "agent-mediated",
|
|
40
|
+
"cost_class": "included",
|
|
41
|
+
"generator_source": "internal-imagegen",
|
|
42
|
+
"default_model": "default",
|
|
43
|
+
"default_task_id": "motionloom-scout-ai-pilot-ingest-2026",
|
|
44
|
+
"license_source": "Internal ImageGen generation; provider-native receipt was not exported",
|
|
45
|
+
"reconstruction_source": "agent-mediated ImageGen source",
|
|
46
|
+
"lab_limit": "Internal ImageGen adapter is scaffold-only",
|
|
47
|
+
},
|
|
48
|
+
"chatgpt": {
|
|
49
|
+
"adapter_id": "openai.chatgpt",
|
|
50
|
+
"kind": "external_provider",
|
|
51
|
+
"invocation_mode": "manual",
|
|
52
|
+
"cost_class": "external",
|
|
53
|
+
"generator_source": "openai-chatgpt",
|
|
54
|
+
"default_model": "chatgpt-image-generation",
|
|
55
|
+
"default_task_id": "motionloom-scout-chatgpt-pilot-2026",
|
|
56
|
+
"license_source": "User-provided ChatGPT generation; provider-native receipt was not exported",
|
|
57
|
+
"reconstruction_source": "user-mediated ChatGPT source",
|
|
58
|
+
"lab_limit": "ChatGPT import adapter is scaffold-only",
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sha256_file(path: Path) -> str:
|
|
64
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sha256_text(value: str) -> str:
|
|
68
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def write_json(path: Path, value: dict[str, Any]) -> None:
|
|
72
|
+
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_png_reader() -> Any:
|
|
76
|
+
source = REPO_ROOT / "scripts" / "asset-consistency.py"
|
|
77
|
+
spec = importlib.util.spec_from_file_location("motionloom_pilot_png", source)
|
|
78
|
+
if spec is None or spec.loader is None:
|
|
79
|
+
raise RuntimeError(f"could not load deterministic PNG reader: {source}")
|
|
80
|
+
module = importlib.util.module_from_spec(spec)
|
|
81
|
+
sys.modules[spec.name] = module
|
|
82
|
+
spec.loader.exec_module(module)
|
|
83
|
+
return module.PNGImage
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_urls(values: list[str]) -> dict[str, str]:
|
|
87
|
+
result: dict[str, str] = {}
|
|
88
|
+
for value in values:
|
|
89
|
+
frame_id, separator, url = value.partition("=")
|
|
90
|
+
if not separator or frame_id not in {item[0] for item in FRAME_ORDER} or not url.startswith(("/", "https://", "http://")):
|
|
91
|
+
raise ValueError("--frame-url must look like idle=/manus-storage/frame.png")
|
|
92
|
+
result[frame_id] = url
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def measured_frame(PNGImage: Any, path: Path) -> dict[str, Any]:
|
|
97
|
+
image = PNGImage(path)
|
|
98
|
+
supports_alpha = image.color_type in {4, 6} or (image.color_type == 3 and bool(image._transparency))
|
|
99
|
+
if not supports_alpha:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"{path.name} is color type {image.color_type} and has no alpha channel; "
|
|
102
|
+
"do not treat a painted checkerboard as transparency"
|
|
103
|
+
)
|
|
104
|
+
total = image.width * image.height
|
|
105
|
+
alpha_pixels = image.alpha_count(1)
|
|
106
|
+
if alpha_pixels == total:
|
|
107
|
+
raise ValueError(f"{path.name} has no transparent padding; an isolated alpha frame is required")
|
|
108
|
+
bbox = image.alpha_bbox(1)
|
|
109
|
+
if bbox is None:
|
|
110
|
+
raise ValueError(f"{path.name} has no visible alpha pixels")
|
|
111
|
+
return {
|
|
112
|
+
"width": image.width,
|
|
113
|
+
"height": image.height,
|
|
114
|
+
"alpha_pixels": alpha_pixels,
|
|
115
|
+
"alpha_bbox": bbox,
|
|
116
|
+
"sha256": sha256_file(path),
|
|
117
|
+
"bytes": path.stat().st_size,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def require_clean_pilot_geometry(frame_id: str, data: dict[str, Any]) -> None:
|
|
122
|
+
"""Reject canvas-spanning residue for this isolated, single-character pilot.
|
|
123
|
+
|
|
124
|
+
Alpha isolation can deliberately remove only edge-connected background. A
|
|
125
|
+
detached band may therefore remain alpha-opaque without touching an edge.
|
|
126
|
+
The pilot has a narrow, centred single-character contract, so its measured
|
|
127
|
+
alpha bounds must retain padding on every side and cannot occupy most of a
|
|
128
|
+
canvas width. This is a geometry guard, not a human-quality approval.
|
|
129
|
+
"""
|
|
130
|
+
bbox = data["alpha_bbox"]
|
|
131
|
+
width, height = int(data["width"]), int(data["height"])
|
|
132
|
+
minimum_padding = max(1, round(min(width, height) * 0.05))
|
|
133
|
+
paddings = {
|
|
134
|
+
"left": int(bbox["x"]),
|
|
135
|
+
"top": int(bbox["y"]),
|
|
136
|
+
"right": width - (int(bbox["x"]) + int(bbox["width"])),
|
|
137
|
+
"bottom": height - (int(bbox["y"]) + int(bbox["height"])),
|
|
138
|
+
}
|
|
139
|
+
if min(paddings.values()) < minimum_padding:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"{frame_id} does not retain {minimum_padding}px clean padding on every edge; "
|
|
142
|
+
f"measured {paddings}. Reject possible canvas-spanning contamination."
|
|
143
|
+
)
|
|
144
|
+
if int(bbox["width"]) / width > 0.6:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"{frame_id} alpha subject spans {int(bbox['width'])}/{width}px of the canvas width; "
|
|
147
|
+
"reject possible detached horizontal contamination."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def partial_frame_record(frame_id: str, source: Path, data: dict[str, Any] | None, error: str | None) -> dict[str, Any]:
|
|
152
|
+
"""Describe a rejected source without emitting an ingestable candidate."""
|
|
153
|
+
record: dict[str, Any] = {
|
|
154
|
+
"role": frame_id,
|
|
155
|
+
"source": str(source),
|
|
156
|
+
"sha256": sha256_file(source),
|
|
157
|
+
"bytes": source.stat().st_size,
|
|
158
|
+
}
|
|
159
|
+
if data is not None:
|
|
160
|
+
bbox = data["alpha_bbox"]
|
|
161
|
+
record["canvas"] = [data["width"], data["height"]]
|
|
162
|
+
record["alpha_bbox"] = [bbox["x"], bbox["y"], bbox["width"], bbox["height"]]
|
|
163
|
+
record["padding"] = {
|
|
164
|
+
"left": bbox["x"],
|
|
165
|
+
"top": bbox["y"],
|
|
166
|
+
"right": data["width"] - (bbox["x"] + bbox["width"]),
|
|
167
|
+
"bottom": data["height"] - (bbox["y"] + bbox["height"]),
|
|
168
|
+
}
|
|
169
|
+
if error is not None:
|
|
170
|
+
record["error"] = error
|
|
171
|
+
return record
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def write_partial_handoff(
|
|
175
|
+
output: Path,
|
|
176
|
+
provider: dict[str, str],
|
|
177
|
+
provider_model: str,
|
|
178
|
+
provider_task_id: str,
|
|
179
|
+
timestamp: str,
|
|
180
|
+
rejected_frames: list[dict[str, Any]],
|
|
181
|
+
errors: list[str],
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Write source-bound refusal evidence; never create controls or a candidate."""
|
|
184
|
+
handoff = {
|
|
185
|
+
"contract": "motionloom-ai-pilot-partial-handoff",
|
|
186
|
+
"version": 1,
|
|
187
|
+
"asset_id": ASSET_ID,
|
|
188
|
+
"state": "partial",
|
|
189
|
+
"authority": "ai_generated",
|
|
190
|
+
"production_approved": False,
|
|
191
|
+
"created_at": timestamp,
|
|
192
|
+
"blocked": {
|
|
193
|
+
"reason": "Source preflight failed. No canvas resampling, transparent padding, metadata edit, intake, candidate, render, or approval was emitted.",
|
|
194
|
+
"errors": errors,
|
|
195
|
+
"missing_frames": [entry["role"] for entry in rejected_frames if entry.get("error")],
|
|
196
|
+
"not_emitted": [
|
|
197
|
+
"controls.json",
|
|
198
|
+
"generation receipt",
|
|
199
|
+
"export manifest",
|
|
200
|
+
"asset consistency contract",
|
|
201
|
+
"Artifact Intake report",
|
|
202
|
+
"runtime candidate",
|
|
203
|
+
"runtime render evidence",
|
|
204
|
+
"Dev Lab review candidate",
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
"rejected_source_attempts": [
|
|
208
|
+
{
|
|
209
|
+
"provider": provider["generator_source"],
|
|
210
|
+
"adapter_id": provider["adapter_id"],
|
|
211
|
+
"model": provider_model,
|
|
212
|
+
"task_id": provider_task_id,
|
|
213
|
+
"authority": "ai_generated",
|
|
214
|
+
"state": "rejected_pre_ingest",
|
|
215
|
+
"reason": "Measured source bytes failed the pilot preflight; no transform may bypass the gate.",
|
|
216
|
+
"frames": rejected_frames,
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
"resume_requirements": [
|
|
220
|
+
"Provide independent source frames that satisfy the measured canvas and clean-padding requirements.",
|
|
221
|
+
"Do not resize, pad, crop, isolate, or relabel rejected bytes to bypass the source gate.",
|
|
222
|
+
"Re-run build-ai-pilot and downstream pipeline stages only after preflight succeeds.",
|
|
223
|
+
],
|
|
224
|
+
}
|
|
225
|
+
write_json(output / "partial-handoff.json", handoff)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def frame_entry(frame_id: str, role: str, target: str, file_name: str, data: dict[str, Any], root_relative_output: str) -> dict[str, Any]:
|
|
229
|
+
return {
|
|
230
|
+
"path": f"{root_relative_output}/frames/{file_name}",
|
|
231
|
+
"role": f"walk_{role}",
|
|
232
|
+
"sha256": data["sha256"],
|
|
233
|
+
"bytes": data["bytes"],
|
|
234
|
+
"target": target,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main(argv: list[str] | None = None) -> int:
|
|
239
|
+
parser = argparse.ArgumentParser(description="Build MotionLoom's review-only scout AI pilot workspace")
|
|
240
|
+
parser.add_argument("--idle", required=True, type=Path, help="Alpha-isolated idle PNG")
|
|
241
|
+
parser.add_argument("--contact-right", required=True, type=Path, help="Alpha-isolated contact-right PNG")
|
|
242
|
+
parser.add_argument("--passing", required=True, type=Path, help="Alpha-isolated passing PNG")
|
|
243
|
+
parser.add_argument("--contact-left", required=True, type=Path, help="Alpha-isolated contact-left PNG")
|
|
244
|
+
parser.add_argument("--root", type=Path, default=REPO_ROOT, help="MotionLoom repository root")
|
|
245
|
+
parser.add_argument(
|
|
246
|
+
"--output",
|
|
247
|
+
default=".motionloom/pilots/ai-pilot-scout",
|
|
248
|
+
help="Safe relative output directory inside --root",
|
|
249
|
+
)
|
|
250
|
+
parser.add_argument("--frame-url", action="append", default=[], help="Optional frame URL: idle=/manus-storage/frame.png")
|
|
251
|
+
parser.add_argument("--generated-at", default=None, help="ISO-8601 evidence timestamp; defaults to build time")
|
|
252
|
+
parser.add_argument("--provider", choices=sorted(PROVIDER_PROFILES), default="internal-imagegen", help="Truthful source provider profile")
|
|
253
|
+
parser.add_argument("--provider-model", default=None, help="Provider/model label for provenance; defaults to profile value")
|
|
254
|
+
parser.add_argument("--provider-task-id", default=None, help="Provider or user-mediated task identifier for provenance")
|
|
255
|
+
parser.add_argument("--overwrite", action="store_true", help="Replace an existing pilot workspace")
|
|
256
|
+
args = parser.parse_args(argv)
|
|
257
|
+
|
|
258
|
+
root = args.root.resolve()
|
|
259
|
+
output = (root / args.output).resolve()
|
|
260
|
+
try:
|
|
261
|
+
output.relative_to(root)
|
|
262
|
+
except ValueError as exc:
|
|
263
|
+
raise SystemExit("--output must stay inside --root") from exc
|
|
264
|
+
if output.exists():
|
|
265
|
+
if not args.overwrite:
|
|
266
|
+
raise SystemExit(f"pilot workspace already exists: {output}; pass --overwrite to replace it")
|
|
267
|
+
shutil.rmtree(output)
|
|
268
|
+
urls = parse_urls(args.frame_url)
|
|
269
|
+
timestamp = args.generated_at or datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
270
|
+
provider = PROVIDER_PROFILES[args.provider]
|
|
271
|
+
provider_model = args.provider_model or provider["default_model"]
|
|
272
|
+
provider_task_id = args.provider_task_id or provider["default_task_id"]
|
|
273
|
+
|
|
274
|
+
sources = {
|
|
275
|
+
"idle": args.idle,
|
|
276
|
+
"contact-right": args.contact_right,
|
|
277
|
+
"passing": args.passing,
|
|
278
|
+
"contact-left": args.contact_left,
|
|
279
|
+
}
|
|
280
|
+
for frame_id, source in sources.items():
|
|
281
|
+
if not source.is_file():
|
|
282
|
+
raise SystemExit(f"missing {frame_id} source image: {source}")
|
|
283
|
+
|
|
284
|
+
output.mkdir(parents=True)
|
|
285
|
+
frames_dir = output / "frames"
|
|
286
|
+
frames_dir.mkdir()
|
|
287
|
+
PNGImage = load_png_reader()
|
|
288
|
+
frames: dict[str, dict[str, Any]] = {}
|
|
289
|
+
rejected_frames: list[dict[str, Any]] = []
|
|
290
|
+
preflight_errors: list[str] = []
|
|
291
|
+
for frame_id, role, _ in FRAME_ORDER:
|
|
292
|
+
destination = frames_dir / f"scout-{frame_id}.png"
|
|
293
|
+
shutil.copy2(sources[frame_id], destination)
|
|
294
|
+
data: dict[str, Any] | None = None
|
|
295
|
+
try:
|
|
296
|
+
data = measured_frame(PNGImage, destination)
|
|
297
|
+
require_clean_pilot_geometry(frame_id, data)
|
|
298
|
+
except ValueError as exc:
|
|
299
|
+
error = str(exc)
|
|
300
|
+
preflight_errors.append(error)
|
|
301
|
+
rejected_frames.append(partial_frame_record(frame_id, destination, data, error))
|
|
302
|
+
continue
|
|
303
|
+
assert data is not None
|
|
304
|
+
frames[frame_id] = data
|
|
305
|
+
rejected_frames.append(partial_frame_record(frame_id, destination, data, None))
|
|
306
|
+
|
|
307
|
+
if not preflight_errors:
|
|
308
|
+
canvas_sizes = {(item["width"], item["height"]) for item in frames.values()}
|
|
309
|
+
if len(canvas_sizes) != 1:
|
|
310
|
+
preflight_errors.append(f"all frames must use one canvas; measured {sorted(canvas_sizes)}")
|
|
311
|
+
|
|
312
|
+
if preflight_errors:
|
|
313
|
+
write_partial_handoff(output, provider, provider_model, provider_task_id, timestamp, rejected_frames, preflight_errors)
|
|
314
|
+
print(json.dumps({"status": "partial", "root": str(root), "output": output.relative_to(root).as_posix(), "asset_id": ASSET_ID, "errors": preflight_errors}, indent=2))
|
|
315
|
+
return 2
|
|
316
|
+
|
|
317
|
+
canvas_sizes = {(item["width"], item["height"]) for item in frames.values()}
|
|
318
|
+
width, height = next(iter(canvas_sizes))
|
|
319
|
+
alpha_bboxes = [item["alpha_bbox"] for item in frames.values()]
|
|
320
|
+
footlines = [bbox["y"] + bbox["height"] - 1 for bbox in alpha_bboxes]
|
|
321
|
+
pivots = [(bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] - 1) for bbox in alpha_bboxes]
|
|
322
|
+
reference_pivot = pivots[0]
|
|
323
|
+
pivot_tolerance = max(max(abs(x - reference_pivot[0]), abs(y - reference_pivot[1])) for x, y in pivots)
|
|
324
|
+
footline_tolerance = max(abs(value - footlines[0]) for value in footlines)
|
|
325
|
+
bbox_tolerance = max(
|
|
326
|
+
max(abs(item["width"] - alpha_bboxes[0]["width"]), abs(item["height"] - alpha_bboxes[0]["height"]))
|
|
327
|
+
for item in alpha_bboxes
|
|
328
|
+
)
|
|
329
|
+
min_alpha = min(int(item["alpha_pixels"]) for item in frames.values())
|
|
330
|
+
idle_hash = frames["idle"]["sha256"]
|
|
331
|
+
prompt_capsule = (
|
|
332
|
+
f"MotionLoom scout robot AI pilot imported from {provider['generator_source']}. "
|
|
333
|
+
"Post-hoc ingest capsule only; provider-native prompt and task receipt were not exported. "
|
|
334
|
+
"The capsule binds reviewed output bytes without asserting a human approval or artist authorship."
|
|
335
|
+
)
|
|
336
|
+
prompt_hash = sha256_text(prompt_capsule)
|
|
337
|
+
motion_hash = sha256_text("walk_pilot_4frame:idle,contact-right,passing,contact-left")
|
|
338
|
+
style_hash = sha256_text("cream-orange-black-cyan pixel-art scout robot style lock")
|
|
339
|
+
|
|
340
|
+
relative_output = output.relative_to(root).as_posix()
|
|
341
|
+
geometry_frames: list[dict[str, Any]] = []
|
|
342
|
+
receipt_outputs: list[dict[str, Any]] = []
|
|
343
|
+
export_outputs: list[dict[str, Any]] = []
|
|
344
|
+
provenance_files: list[dict[str, Any]] = []
|
|
345
|
+
for index, (frame_id, role, target) in enumerate(FRAME_ORDER):
|
|
346
|
+
data = frames[frame_id]
|
|
347
|
+
bbox = data["alpha_bbox"]
|
|
348
|
+
pivot = pivots[index]
|
|
349
|
+
filename = f"scout-{frame_id}.png"
|
|
350
|
+
geometry_frames.append(
|
|
351
|
+
{
|
|
352
|
+
"frame_id": f"scout-{frame_id}",
|
|
353
|
+
"image": f"frames/{filename}",
|
|
354
|
+
"rect": {"x": 0, "y": 0, "width": width, "height": height},
|
|
355
|
+
"alpha_bbox": bbox,
|
|
356
|
+
"pivot": {"x": pivot[0], "y": pivot[1], "space": "pixels"},
|
|
357
|
+
"footline_px": footlines[index],
|
|
358
|
+
"safe_rect": bbox,
|
|
359
|
+
"bleed_margin_px": 0,
|
|
360
|
+
"sha256": data["sha256"],
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
record = frame_entry(frame_id, role, target, filename, data, relative_output)
|
|
364
|
+
receipt_outputs.append({key: value for key, value in record.items() if key != "target"})
|
|
365
|
+
export_outputs.append(record)
|
|
366
|
+
provenance_files.append(
|
|
367
|
+
{
|
|
368
|
+
"path": record["path"],
|
|
369
|
+
"role": record["role"],
|
|
370
|
+
"sha256": record["sha256"],
|
|
371
|
+
"bytes": record["bytes"],
|
|
372
|
+
}
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
controls = {
|
|
376
|
+
"schema_version": "0.1",
|
|
377
|
+
"control_id": "scout-ai-pilot-controls-v1",
|
|
378
|
+
"asset_id": ASSET_ID,
|
|
379
|
+
"created_at": timestamp,
|
|
380
|
+
"references": [{"id": "scout-idle-master", "path": f"{relative_output}/frames/scout-idle.png", "sha256": idle_hash, "role": "identity"}],
|
|
381
|
+
"tracks": [
|
|
382
|
+
{"id": "identity-lock", "kind": "identity", "binding": "required", "value_hash": idle_hash, "samples": 1},
|
|
383
|
+
{"id": "style-lock", "kind": "style", "binding": "advisory", "value_hash": style_hash, "samples": 4},
|
|
384
|
+
{"id": "pose-motion", "kind": "pose", "binding": "required", "value_hash": motion_hash, "samples": 4},
|
|
385
|
+
],
|
|
386
|
+
"output_profile": {"kind": "frame_sequence", "fps": 8, "expected_frame_count": 4, "loop": True},
|
|
387
|
+
}
|
|
388
|
+
provenance = {
|
|
389
|
+
"schema_version": "1.0",
|
|
390
|
+
"provenance_id": "scout-ai-pilot-provenance-v1",
|
|
391
|
+
"task_id": provider_task_id,
|
|
392
|
+
"scene": "scout-walk",
|
|
393
|
+
"created_at": timestamp,
|
|
394
|
+
"asset": {"id": ASSET_ID, "path": f"{relative_output}/frames/scout-idle.png", "type": "frame_sequence", "framework": "canvas", "version": "pilot-v1"},
|
|
395
|
+
"authority": "ai_generated",
|
|
396
|
+
"readiness": "runtime_ready",
|
|
397
|
+
"generator": {"model": provider_model, "task_id": provider_task_id, "source": provider["generator_source"], "generated_at": timestamp, "prompt_hash": prompt_hash, "agent": "MotionLoom"},
|
|
398
|
+
"license": {"spdx": "LicenseRef-Generated-Pilot", "source": provider["license_source"], "attribution": "AI-generated scout pilot; human review remains required"},
|
|
399
|
+
"files": provenance_files,
|
|
400
|
+
"provenance_chain": [{"step": "ingest", "actor": "MotionLoom", "source": f"post-hoc hash-bound local ingest from {provider['reconstruction_source']}; not a provider-native receipt", "timestamp": timestamp}],
|
|
401
|
+
"runtime_evidence": {"status": "not_run", "runtime": "canvas", "tested_at": timestamp},
|
|
402
|
+
}
|
|
403
|
+
receipt = {
|
|
404
|
+
"schema_version": "0.1",
|
|
405
|
+
"receipt_id": "scout-ai-pilot-receipt-v1",
|
|
406
|
+
"created_at": timestamp,
|
|
407
|
+
"asset": {"id": ASSET_ID, "kind": "frame_sequence", "intended_use": "runtime_candidate"},
|
|
408
|
+
"authority": "ai_generated",
|
|
409
|
+
"provider": {"adapter_id": provider["adapter_id"], "kind": provider["kind"], "invocation_mode": provider["invocation_mode"], "task_id": provider_task_id, "model": provider_model, "generated_at": timestamp, "cost_class": provider["cost_class"], "prompt_hash": prompt_hash},
|
|
410
|
+
"control_track_ref": f"{relative_output}/controls.json",
|
|
411
|
+
"provenance_ref": f"{relative_output}/provenance.json",
|
|
412
|
+
"outputs": receipt_outputs,
|
|
413
|
+
}
|
|
414
|
+
export = {
|
|
415
|
+
"schema_version": "0.1",
|
|
416
|
+
"manifest_id": "scout-ai-pilot-export-v1",
|
|
417
|
+
"asset_id": ASSET_ID,
|
|
418
|
+
"created_at": timestamp,
|
|
419
|
+
"receipt_ref": f"{relative_output}/receipt.json",
|
|
420
|
+
"control_track_ref": f"{relative_output}/controls.json",
|
|
421
|
+
"outputs": export_outputs,
|
|
422
|
+
}
|
|
423
|
+
identity = {
|
|
424
|
+
"schema_version": "0.1",
|
|
425
|
+
"asset_id": ASSET_ID,
|
|
426
|
+
"asset_kind": "character",
|
|
427
|
+
"identity": {
|
|
428
|
+
"subject_id": "scout-robot",
|
|
429
|
+
"reference_hashes": [idle_hash],
|
|
430
|
+
"camera": {"projection": "orthographic", "width": width, "height": height, "focal_length": 1},
|
|
431
|
+
"coordinate_system": "screen_y_down",
|
|
432
|
+
"scale": 1,
|
|
433
|
+
"pivot": {"x": reference_pivot[0] / width, "y": reference_pivot[1] / height, "space": "normalized_canvas"},
|
|
434
|
+
"palette_lock": {"colors": ["#f7e4c6", "#f47c15", "#121116", "#18d8f8"], "delta_e_tolerance": 28},
|
|
435
|
+
"style_profile": "pixel-art scout robot pilot",
|
|
436
|
+
"lighting_profile": "flat game sprite lighting",
|
|
437
|
+
},
|
|
438
|
+
"derivation": {"origin": "ai_generated", "generator": {"model": provider_model, "task_id": provider_task_id, "prompt_hash": prompt_hash}, "source_refs": ["provenance.json", "receipt-reconstruction.json"]},
|
|
439
|
+
"provenance_ref": "provenance.json",
|
|
440
|
+
"notes": "Identity controls are a post-hoc ingest representation. They bind output hashes but do not prove provider-native generation controls.",
|
|
441
|
+
}
|
|
442
|
+
action_set = {
|
|
443
|
+
"schema_version": "0.1",
|
|
444
|
+
"asset_identity": ASSET_ID,
|
|
445
|
+
"actions": [{"action_id": "walk-pilot", "fps": 8, "frames": [f"frames/scout-{frame_id}.png" for frame_id, _, _ in FRAME_ORDER], "loop": True, "events": ["foot_contact_right", "passing", "foot_contact_left"]}],
|
|
446
|
+
"invariants": {"canvas_width": width, "canvas_height": height, "pivot_tolerance_px": pivot_tolerance, "footline_tolerance_px": footline_tolerance, "bbox_drift_tolerance_px": bbox_tolerance, "min_alpha_pixels": min_alpha},
|
|
447
|
+
}
|
|
448
|
+
frame_geometry = {
|
|
449
|
+
"schema_version": "0.1",
|
|
450
|
+
"asset_identity": ASSET_ID,
|
|
451
|
+
"canvas": {"width": width, "height": height, "color_space": "srgb", "alpha_mode": "straight"},
|
|
452
|
+
"invariants": {"pivot_tolerance_px": pivot_tolerance, "footline_tolerance_px": footline_tolerance, "bbox_drift_tolerance_px": bbox_tolerance, "min_alpha_pixels": min_alpha, "allow_external_opaque_pixels": False},
|
|
453
|
+
"frames": geometry_frames,
|
|
454
|
+
}
|
|
455
|
+
candidate = {
|
|
456
|
+
"schema_version": "0.1",
|
|
457
|
+
"candidate_id": "scout-ai-pilot-candidate-v1",
|
|
458
|
+
"asset_id": ASSET_ID,
|
|
459
|
+
"created_at": timestamp,
|
|
460
|
+
"artifact_intake": {"registry": "artifact-adapter-registry.json", "receipt": f"{relative_output}/receipt.json", "controls": f"{relative_output}/controls.json", "export_manifest": f"{relative_output}/export.json"},
|
|
461
|
+
"consistency": {"asset_identity": f"{relative_output}/asset-identity.json", "action_set": f"{relative_output}/action-set.json", "frame_geometry": f"{relative_output}/frame-geometry.json"},
|
|
462
|
+
"runtime": {"target": "canvas", "review_required": True},
|
|
463
|
+
}
|
|
464
|
+
reconstruction = {
|
|
465
|
+
"artifact": ASSET_ID,
|
|
466
|
+
"status": "incomplete_provider_receipt",
|
|
467
|
+
"created_at": timestamp,
|
|
468
|
+
"known": ["output bytes", "output SHA-256", provider["reconstruction_source"], "post-hoc control capsule hash"],
|
|
469
|
+
"unknown": ["provider-native request ID", "provider-native prompt record", "provider-native seed", "provider-native output receipt"],
|
|
470
|
+
"governance": {"authority": "ai_generated", "human_review": "required", "production_approved": False, "attestation_approval": False},
|
|
471
|
+
}
|
|
472
|
+
lab_evidence = {
|
|
473
|
+
"pilot_id": PILOT_ID,
|
|
474
|
+
"asset_id": ASSET_ID,
|
|
475
|
+
"created_at": timestamp,
|
|
476
|
+
"state": "review_required",
|
|
477
|
+
"authority": "ai_generated",
|
|
478
|
+
"production_approved": False,
|
|
479
|
+
"runtime_verified": False,
|
|
480
|
+
"limits": [provider["lab_limit"], "Provider-native generation receipt was unavailable", "Runtime proof is not yet present", "No human approval exists"],
|
|
481
|
+
"frames": [
|
|
482
|
+
{"id": frame_id, "label": role.replace("_", " "), "path": f"frames/scout-{frame_id}.png", "url": urls.get(frame_id), "sha256": frames[frame_id]["sha256"], "alpha_bbox": frames[frame_id]["alpha_bbox"]}
|
|
483
|
+
for frame_id, role, _ in FRAME_ORDER
|
|
484
|
+
],
|
|
485
|
+
}
|
|
486
|
+
for filename, document in (
|
|
487
|
+
("controls.json", controls),
|
|
488
|
+
("provenance.json", provenance),
|
|
489
|
+
("receipt.json", receipt),
|
|
490
|
+
("export.json", export),
|
|
491
|
+
("asset-identity.json", identity),
|
|
492
|
+
("action-set.json", action_set),
|
|
493
|
+
("frame-geometry.json", frame_geometry),
|
|
494
|
+
("candidate.json", candidate),
|
|
495
|
+
("receipt-reconstruction.json", reconstruction),
|
|
496
|
+
("devlab-pilot-evidence.json", lab_evidence),
|
|
497
|
+
):
|
|
498
|
+
write_json(output / filename, document)
|
|
499
|
+
print(json.dumps({"status": "built", "root": str(root), "output": relative_output, "asset_id": ASSET_ID, "frame_count": len(frames), "alpha_geometry": {"footline_range": [min(footlines), max(footlines)], "pivot_tolerance_px": pivot_tolerance, "footline_tolerance_px": footline_tolerance, "bbox_drift_tolerance_px": bbox_tolerance}}, indent=2))
|
|
500
|
+
return 0
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
if __name__ == "__main__":
|
|
504
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministically isolate a high-contrast, edge-connected flat background.
|
|
3
|
+
|
|
4
|
+
This is a narrow recovery utility for generated PNGs where an image provider
|
|
5
|
+
painted a dark background despite a transparency request. It flood-fills only
|
|
6
|
+
edge-connected pixels within a stated RGB-distance of an observed edge color.
|
|
7
|
+
It does not claim the input was provider-native alpha, alter provenance, or
|
|
8
|
+
approve an asset. Review the JSON report before using the result in a bundle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import struct
|
|
18
|
+
import sys
|
|
19
|
+
import zlib
|
|
20
|
+
from collections import deque
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from statistics import median
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
27
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def digest(path: Path) -> str:
|
|
31
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_png_reader() -> Any:
|
|
35
|
+
source = ROOT / "scripts" / "asset-consistency.py"
|
|
36
|
+
spec = importlib.util.spec_from_file_location("motionloom_alpha_isolation_png", source)
|
|
37
|
+
if spec is None or spec.loader is None:
|
|
38
|
+
raise RuntimeError(f"could not load PNG reader: {source}")
|
|
39
|
+
module = importlib.util.module_from_spec(spec)
|
|
40
|
+
sys.modules[spec.name] = module
|
|
41
|
+
spec.loader.exec_module(module)
|
|
42
|
+
return module.PNGImage
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def median_edge_color(pixels: list[tuple[int, int, int, int]], width: int, height: int) -> tuple[int, int, int]:
|
|
46
|
+
indices = set(range(width)) | {width * (height - 1) + x for x in range(width)}
|
|
47
|
+
indices.update(y * width for y in range(height))
|
|
48
|
+
indices.update(y * width + width - 1 for y in range(height))
|
|
49
|
+
values = [pixels[index] for index in sorted(indices)]
|
|
50
|
+
return tuple(int(median([pixel[channel] for pixel in values])) for channel in range(3))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def distance(pixel: tuple[int, int, int, int], color: tuple[int, int, int]) -> int:
|
|
54
|
+
return sum(abs(pixel[index] - color[index]) for index in range(3))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def isolate(pixels: list[tuple[int, int, int, int]], width: int, height: int, background: tuple[int, int, int], tolerance: int) -> set[int]:
|
|
58
|
+
def matches(index: int) -> bool:
|
|
59
|
+
return pixels[index][3] > 0 and distance(pixels[index], background) <= tolerance
|
|
60
|
+
edges = set(range(width)) | {width * (height - 1) + x for x in range(width)}
|
|
61
|
+
edges.update(y * width for y in range(height))
|
|
62
|
+
edges.update(y * width + width - 1 for y in range(height))
|
|
63
|
+
queue = deque(index for index in edges if matches(index))
|
|
64
|
+
visited = set(queue)
|
|
65
|
+
while queue:
|
|
66
|
+
index = queue.popleft()
|
|
67
|
+
x, y = index % width, index // width
|
|
68
|
+
for neighbour in (index - 1 if x else None, index + 1 if x + 1 < width else None, index - width if y else None, index + width if y + 1 < height else None):
|
|
69
|
+
if neighbour is not None and neighbour not in visited and matches(neighbour):
|
|
70
|
+
visited.add(neighbour)
|
|
71
|
+
queue.append(neighbour)
|
|
72
|
+
return visited
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def png_rgba(width: int, height: int, pixels: list[tuple[int, int, int, int]]) -> bytes:
|
|
76
|
+
raw = bytearray()
|
|
77
|
+
for y in range(height):
|
|
78
|
+
raw.append(0)
|
|
79
|
+
for pixel in pixels[y * width : (y + 1) * width]:
|
|
80
|
+
raw.extend(pixel)
|
|
81
|
+
def chunk(kind: bytes, content: bytes) -> bytes:
|
|
82
|
+
return struct.pack(">I", len(content)) + kind + content + struct.pack(">I", zlib.crc32(kind + content) & 0xFFFFFFFF)
|
|
83
|
+
return PNG_SIGNATURE + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(bytes(raw), level=9)) + chunk(b"IEND", b"")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main(argv: list[str] | None = None) -> int:
|
|
87
|
+
parser = argparse.ArgumentParser(description="Flood-fill a flat edge-connected background into alpha=0")
|
|
88
|
+
parser.add_argument("input", type=Path)
|
|
89
|
+
parser.add_argument("output", type=Path)
|
|
90
|
+
parser.add_argument("--report", type=Path, help="Optional JSON evidence report")
|
|
91
|
+
parser.add_argument("--tolerance", type=int, default=24, help="Manhattan RGB distance from edge background; 0-765")
|
|
92
|
+
parser.add_argument("--allow-edge-foreground", action="store_true", help="Permit opaque pixels on the canvas edge; only use for intentionally edge-cropped art")
|
|
93
|
+
args = parser.parse_args(argv)
|
|
94
|
+
if not 0 <= args.tolerance <= 765:
|
|
95
|
+
raise SystemExit("--tolerance must be in 0..765")
|
|
96
|
+
if not args.input.is_file():
|
|
97
|
+
raise SystemExit(f"input is missing: {args.input}")
|
|
98
|
+
if args.output.resolve() == args.input.resolve():
|
|
99
|
+
raise SystemExit("output must differ from input")
|
|
100
|
+
PNGImage = load_png_reader()
|
|
101
|
+
image = PNGImage(args.input)
|
|
102
|
+
pixels = image.rgba()
|
|
103
|
+
background = median_edge_color(pixels, image.width, image.height)
|
|
104
|
+
removed = isolate(pixels, image.width, image.height, background, args.tolerance)
|
|
105
|
+
if not removed:
|
|
106
|
+
raise SystemExit("no edge-connected background pixels matched; do not use a larger tolerance without visual review")
|
|
107
|
+
output_pixels = [(r, g, b, 0 if index in removed else a) for index, (r, g, b, a) in enumerate(pixels)]
|
|
108
|
+
foreground = sum(1 for _, _, _, alpha in output_pixels if alpha > 0)
|
|
109
|
+
if foreground == 0:
|
|
110
|
+
raise SystemExit("isolation would remove all pixels; lower tolerance or provide a different source")
|
|
111
|
+
edge_indices = set(range(image.width)) | {image.width * (image.height - 1) + x for x in range(image.width)}
|
|
112
|
+
edge_indices.update(y * image.width for y in range(image.height))
|
|
113
|
+
edge_indices.update(y * image.width + image.width - 1 for y in range(image.height))
|
|
114
|
+
residual_edge_pixels = sum(1 for index in edge_indices if output_pixels[index][3] > 0)
|
|
115
|
+
if residual_edge_pixels and not args.allow_edge_foreground:
|
|
116
|
+
raise SystemExit(
|
|
117
|
+
f"{residual_edge_pixels} opaque pixel(s) remain at the canvas edge after isolation; "
|
|
118
|
+
"this is likely residual background contamination. Use a cleaner source, or explicitly review and pass --allow-edge-foreground for intentionally cropped art."
|
|
119
|
+
)
|
|
120
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
args.output.write_bytes(png_rgba(image.width, image.height, output_pixels))
|
|
122
|
+
report = {
|
|
123
|
+
"contract": "alpha-isolation-report",
|
|
124
|
+
"mode": "edge_connected_flat_background",
|
|
125
|
+
"input": str(args.input),
|
|
126
|
+
"input_sha256": digest(args.input),
|
|
127
|
+
"output": str(args.output),
|
|
128
|
+
"output_sha256": digest(args.output),
|
|
129
|
+
"canvas": {"width": image.width, "height": image.height},
|
|
130
|
+
"observed_edge_background_rgb": list(background),
|
|
131
|
+
"tolerance": args.tolerance,
|
|
132
|
+
"removed_pixels": len(removed),
|
|
133
|
+
"foreground_alpha_pixels": foreground,
|
|
134
|
+
"residual_edge_foreground_pixels": residual_edge_pixels,
|
|
135
|
+
"edge_foreground_allowed": args.allow_edge_foreground,
|
|
136
|
+
"human_review_required": True,
|
|
137
|
+
"production_approved": False,
|
|
138
|
+
}
|
|
139
|
+
if args.report:
|
|
140
|
+
args.report.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
142
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
raise SystemExit(main())
|