File size: 14,941 Bytes
5c331a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
"""Astra visual inspection and run-isolated fx repair planning."""
import base64
import json
import os
from pathlib import Path
import re
import subprocess
import shutil

import httpx
from PIL import Image

from .contracts import Refinement
from .mesh import apply_refinement, describe, export
from .render import render

MODEL = "openai/gpt-6-astra"
FX_RUNTIME = Path(__file__).parent / "fx_runtime"
ROOT = Path(__file__).resolve().parents[1]
RUNTIME = ROOT / ".runtime"


def preflight():
    if not os.environ.get("OPENROUTER_API_KEY"):
        raise RuntimeError("Set the OPENROUTER_API_KEY Space secret before generating.")
    if not shutil.which("node") or not (FX_RUNTIME / "node_modules/libfx/package.json").is_file():
        raise RuntimeError("Install Node 20+ and run npm ci --prefix studio/fx_runtime --ignore-scripts.")
    from .render import check_renderer
    check_renderer()


def image_data_url(path):
    path = Path(path)
    with Image.open(path) as image:
        mime = Image.MIME.get(image.format)
    if mime not in {"image/png", "image/jpeg", "image/webp"}:
        raise ValueError("Astra evidence must be PNG, JPEG, or WebP.")
    return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode()


def vision(prompt, images):
    content = [{"type": "text", "text": prompt}]
    for path in images:
        content.append({"type": "image_url", "image_url": {"url": image_data_url(path)}})
    with httpx.Client(timeout=120) as client:
        response = client.post("https://openrouter.ai/api/v1/chat/completions",
                               headers={"Authorization": "Bearer " + os.environ["OPENROUTER_API_KEY"]},
                               json={"model": MODEL, "messages": [{"role": "user", "content": content}],
                                     "max_tokens": 8192, "reasoning": {"effort": "low"}, "temperature": 0.2})
        if response.status_code != 200:
            raise RuntimeError(f"Astra vision endpoint returned HTTP {response.status_code}; no substitute model used.")
        payload = response.json()
        if payload.get("model") != MODEL:
            raise RuntimeError("The visual reviewer did not return the requested Astra model.")
        result = payload["choices"][0]["message"]["content"]
        if not isinstance(result, str) or not result.strip():
            raise RuntimeError("Astra returned an empty visual assessment; no refinement was accepted.")
        return result


def json_object(text):
    text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip())
    return json.loads(text)


def plan_with_fx(brief, parts, assessment, folder, images=(), previous=None):
    skill = RUNTIME / "orc-skills/game-dev/game-model-cleanup"
    instructions = (skill / "SKILL.md").read_text() + "\n" + (skill / "WORKFLOW.md").read_text()
    prompt = f"""Apply the game-model-cleanup skill below to this asset. This application's contract replaces the
skill's Final Stand-specific paths, roster, animation and equipment requirements. The source is immutable.
Inspect /work/parts.json, /work/assessment.txt, /work/schema.json and the cleanup skill using the virtual
bash tool. Reference and before renders are attached. Return only a JSON repair plan matching the schema.
Do not invent missing anatomy or features. All commands execute only in this run's in-memory filesystem.
You may rename existing parts, remove duplicate/degenerate faces, propose small PBR/color corrections,
and set project_reference_texture=true for evidence-supported reference texture transfer. This operator
projects only visible foreground reference pixels, retains occluded/back-facing texels and all PBR channels,
and never changes geometry or UVs. Use it for visible front texture mismatch when reference pose and model
silhouette align. Do not use projection for different poses or to repair geometry. reference_front_axis is
the outward viewing direction AFTER Blender glTF import, with Z up; canonical front is -Y. It is NOT the
source glTF Y-up frame. Choose +Y/-X/+X only if the rendered orientation establishes that front.
Use projection_strength between 0 and 1; 0.9 is appropriate for strong matching-reference correction.
Color gains are multiplicative RGB gains, each between 0.75 and 1.25 inclusive, not target RGB colors.
Use [1, 1, 1] to preserve the original color.
Preserve UVs, normals, geometry identity, scale and all components. Put unsupported geometry reconstruction,
unseen-surface repainting or rigging needs in unresolved. Do not mark supported projection as unsupported. Empty edits are appropriate when evidence supports no change.
Brief: {brief}
Parts: {json.dumps(describe(parts))}
Astra visual inspection: {assessment}
Schema: {json.dumps(Refinement.model_json_schema())}
Skill and workflow:\n{instructions}"""
    env = {key: value for key, value in os.environ.items() if key in {
        "PATH", "LANG", "SSL_CERT_FILE", "OPENROUTER_API_KEY"}}
    files = {"/work/parts.json": json.dumps(describe(parts)),
             "/work/assessment.txt": assessment,
             "/work/schema.json": json.dumps(Refinement.model_json_schema()),
             "/work/cleanup-skill.md": instructions}
    if previous is not None:
        files["/work/previous-plan.json"] = json.dumps(previous["plan"])
        files["/work/previous-verdict.json"] = json.dumps(previous["verdict"])
        prompt += ("\nThis is a repair retry. Read /work/previous-plan.json and /work/previous-verdict.json. "
                   "Image order is reference, immutable original views, previous rejected candidate views. "
                   "The actual reviewer verdict explains the failure. Produce materially different supported "
                   "edits that address it, not the same plan with rewritten prose. All edits start from the "
                   "ORIGINAL parts, never cumulatively from the rejected candidate. If the defect requires "
                   "unsupported reconstruction, preserve source and explain it in unresolved.")
    encoded_images = [image_data_url(p) for p in images]
    feedback = ""
    for attempt in range(1, 4):
        process = subprocess.run([shutil.which("node") or "node", "--max-old-space-size=256",
                                  str(FX_RUNTIME / "run.mjs")],
                                 input=json.dumps({"prompt": prompt + feedback, "files": files,
                                                   "images": encoded_images}),
                                 text=True, capture_output=True, cwd=folder, env=env, timeout=260)
        if process.returncode:
            detail = (process.stderr + "\n" + process.stdout).replace(os.environ["OPENROUTER_API_KEY"], "[redacted]")
            (folder / "fx-error.txt").write_text(detail)
            raise RuntimeError(f"fx refinement failed (exit {process.returncode}): {detail[-1200:]}")
        response = json.loads(process.stdout)
        (folder / f"fx-evidence-{attempt}.json").write_text(json.dumps(response, indent=2))
        text = response.get("final_output") or response.get("output") or ""
        (folder / f"refinement-attempt-{attempt}.txt").write_text(text)
        try:
            plan = Refinement.model_validate(json_object(text))
            known = {key for key, _ in parts}
            edited = [edit.part_id for edit in plan.parts]
            if any(key not in known for key in edited) or len(edited) != len(set(edited)):
                raise ValueError("Use only listed component IDs, each at most once.")
        except ValueError as error:
            (folder / f"refinement-validation-{attempt}.txt").write_text(str(error))
            if attempt == 3:
                raise RuntimeError("fx did not produce a valid repair plan after three attempts; source retained.") from error
            feedback = (f"\nYour previous plan was invalid:\n{text}\nValidation errors:\n{error}\n"
                        "Return a corrected complete JSON plan. Preserve all bounds in the schema.")
            continue
        (folder / "refinement.json").write_text(plan.model_dump_json(indent=2))
        return plan
    raise AssertionError("Unreachable refinement retry state")


def review_candidate(brief, reference, before_views, after_views, plan):
    verdict = json_object(vision(
        "Compare before and after against the reference and brief. Image order: reference, before contact "
        "sheet with individual component previews, after assembled views. Judge only matched assembled views. "
        "Return JSON only with accept (boolean), quality_passed (boolean), unresolved (list of strings), "
        "blocking_issues (list of strings), repairable_with_supported_tools (boolean), and reason (string). "
        "Accept if the after asset has no visible regression in identity, silhouette, texture fidelity or "
        "materials. Independently set quality_passed only if the final asset is usable and faithful to the "
        "reference, with no blocking defects: broken anatomy, major identity loss, obvious texture corruption "
        "or seams. Minor stylization differences may remain unresolved without blocking. Do not excuse visible "
        "bad textures. A no-change or non-regressing result can still fail quality. Name remaining defects. "
        "Supported repair tools are bounded per-part RGB gains, roughness/metallic adjustment, duplicate or "
        "degenerate face cleanup, and visibility-aware front reference texture projection. Set "
        "repairable_with_supported_tools=true only when a different supported plan could address the actual "
        "failure. Missing or broken anatomy, substantial geometry reconstruction, unseen texture repainting "
        "and different reference pose are NOT repairable with these tools. Do not reward changes for their own sake. "
        f"Applied plan: {plan.model_dump_json()} Brief: {brief}", [reference, before_views, after_views]))
    if (type(verdict.get("accept")) is not bool or type(verdict.get("quality_passed")) is not bool
            or type(verdict.get("repairable_with_supported_tools")) is not bool
            or not isinstance(verdict.get("reason"), str)
            or not isinstance(verdict.get("unresolved"), list)
            or any(not isinstance(item, str) for item in verdict["unresolved"])
            or not isinstance(verdict.get("blocking_issues"), list)
            or any(not isinstance(item, str) for item in verdict["blocking_issues"])):
        raise ValueError("Astra returned an invalid post-refinement verdict.")
    verdict["model"] = MODEL
    verdict["unresolved"] = list(dict.fromkeys([*plan.unresolved, *verdict["unresolved"]]))
    verdict["quality_passed"] = verdict["accept"] and verdict["quality_passed"] and not verdict["blocking_issues"]
    verdict["source_retained"] = not verdict["quality_passed"]
    return verdict


def effective_edits(plan):
    data = plan.model_dump(exclude={"summary", "unresolved"})
    data["parts"] = sorted([{key: value for key, value in edit.items() if key != "name"}
                            for edit in data["parts"]], key=lambda edit: edit["part_id"])
    if not data["project_reference_texture"]:
        data.pop("reference_front_axis")
        data.pop("projection_strength")
    return data


def refine(parts, brief, reference, folder):
    folder = Path(folder)
    folder.mkdir(parents=True, exist_ok=True)
    before = folder / "upscaled.glb"
    export(parts, before)
    before_views = render(before, folder / "before", components=True)
    assessment = vision("Inspect this generated asset against the brief. First image is its reference; second is "
                        "a labeled contact sheet of the assembled asset and components. Identify only visible "
                        "defects and name the affected part IDs. Preserve identity and texture channel meaning. "
                        f"Brief: {brief}", [reference, before_views])
    (folder / "visual-review.txt").write_text(assessment)
    previous = None
    previous_views = None
    previous_edits = None
    selected_parts = parts
    selected_views = before_views
    selected_attempt = None
    for number in range(1, 3):
        attempt = folder / f"repair-attempt-{number}"
        attempt.mkdir()
        images = [reference, before_views] + ([previous_views] if previous_views else [])
        plan = plan_with_fx(brief, parts, assessment, attempt, images=images, previous=previous)
        (attempt / "refinement.json").write_text(plan.model_dump_json(indent=2))
        edits = effective_edits(plan)
        if previous is not None and edits == previous_edits:
            verdict = dict(previous["verdict"], accept=False, quality_passed=False, source_retained=True,
                           reason="The retry repeated the rejected edits; source retained for review.",
                           stop_reason="identical_plan")
            (attempt / "verdict.json").write_text(json.dumps(verdict, indent=2))
            break
        candidate = apply_refinement(parts, plan)
        if plan.project_reference_texture:
            from .texture_repair import project_reference
            candidate, projection = project_reference(
                candidate, reference, attempt / "texture-projection",
                front_axis=plan.reference_front_axis, strength=plan.projection_strength)
            (attempt / "texture-projection.json").write_text(json.dumps(projection, indent=2))
        export(candidate, attempt / "candidate.glb")
        after_views = render(attempt / "candidate.glb", attempt / "after")
        verdict = review_candidate(brief, reference, before_views, after_views, plan)
        (attempt / "verdict.json").write_text(json.dumps(verdict, indent=2))
        if verdict["quality_passed"]:
            selected_parts, selected_views, selected_attempt = candidate, after_views, number
            break
        if not verdict["repairable_with_supported_tools"]:
            verdict["stop_reason"] = "unsupported_repairs"
            break
        previous = {"plan": plan.model_dump(), "verdict": verdict}
        previous_views, previous_edits = after_views, edits
    verdict = dict(verdict, attempts=number, selected_attempt=selected_attempt,
                   status="accepted" if selected_attempt else "needs_review",
                   candidate_accept=verdict["accept"], accept=selected_attempt is not None,
                   source_retained=selected_attempt is None)
    if not selected_attempt:
        verdict["quality_passed"] = False
        verdict.setdefault("stop_reason", "repair_limit")
    (folder / "refinement.json").write_text(plan.model_dump_json(indent=2))
    (folder / "verdict.json").write_text(json.dumps(verdict, indent=2))
    export(selected_parts, folder / "candidate.glb")
    if selected_views.is_file():
        shutil.copytree(selected_views.parent, folder / "after", dirs_exist_ok=True)
    return selected_parts, plan, verdict