a18aad4373
Deploy / deploy (push) Successful in 6m6s
The project dropdown rendered garbled labels. EnumProperty does not copy the strings a callback returns — Python has to keep a reference or Blender reads freed memory, which shows as mojibake rather than as a crash. The callback built fresh tuples on every redraw, so each set was collected the moment it returned. The items are now built once when the project list changes and held at module level; the callback returns that list and allocates nothing, which also makes a per-redraw callback free rather than merely correct. Add-on 1.0.2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
10 KiB
Python
290 lines
10 KiB
Python
bl_info = {
|
||
"name": "VFXReview — Submit Render",
|
||
"author": "Two Tales",
|
||
"version": (1, 0, 2),
|
||
"blender": (3, 6, 0),
|
||
"location": "Properties > Output > VFXReview",
|
||
"description": "Queue this .blend on the studio render farm instead of rendering locally",
|
||
"category": "Render",
|
||
}
|
||
|
||
import json
|
||
import os
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
import bpy
|
||
from bpy.props import BoolProperty, EnumProperty, StringProperty
|
||
from bpy.types import AddonPreferences, Operator, Panel
|
||
|
||
|
||
# ── Preferences (per artist machine, like the AE panel's Settings) ────────────
|
||
|
||
class VFXRPreferences(AddonPreferences):
|
||
bl_idname = __name__
|
||
|
||
server_url: StringProperty(
|
||
name="Server URL",
|
||
default="https://review.twotalesvfx.com",
|
||
)
|
||
api_key: StringProperty(
|
||
name="API Key",
|
||
subtype="PASSWORD",
|
||
description="Matches API_SECRET_KEY on the server",
|
||
)
|
||
artist_email: StringProperty(
|
||
name="Artist Email",
|
||
description="Recorded against the job so the queue shows who submitted it",
|
||
)
|
||
|
||
def draw(self, context):
|
||
col = self.layout.column()
|
||
col.prop(self, "server_url")
|
||
col.prop(self, "api_key")
|
||
col.prop(self, "artist_email")
|
||
|
||
|
||
def prefs():
|
||
return bpy.context.preferences.addons[__name__].preferences
|
||
|
||
|
||
ADDON_VERSION = ".".join(str(n) for n in bl_info["version"])
|
||
USER_AGENT = "VFXReviewBlender/%s (Blender %s)" % (ADDON_VERSION, bpy.app.version_string)
|
||
|
||
|
||
# ── API ───────────────────────────────────────────────────────────────────────
|
||
|
||
def api_request(path, payload=None, method="GET"):
|
||
"""Minimal JSON client. urllib keeps the add-on dependency-free."""
|
||
p = prefs()
|
||
if not p.server_url or not p.api_key:
|
||
raise RuntimeError("Set the server URL and API key in Add-on Preferences")
|
||
|
||
url = p.server_url.rstrip("/") + path
|
||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||
req = urllib.request.Request(url, data=data, method=method)
|
||
req.add_header("Authorization", "Bearer " + p.api_key)
|
||
req.add_header("Accept", "application/json")
|
||
# urllib identifies itself as "Python-urllib/3.x" by default, which
|
||
# Cloudflare blocks outright as a scripted signature (error 1010). Sending a
|
||
# real name for this tool is also the honest thing: the studio's logs should
|
||
# say what is calling them.
|
||
req.add_header("User-Agent", USER_AGENT)
|
||
if data:
|
||
req.add_header("Content-Type", "application/json")
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=30) as res:
|
||
return json.loads(res.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as e:
|
||
body = e.read().decode("utf-8", "replace")
|
||
try:
|
||
parsed = json.loads(body)
|
||
except Exception:
|
||
parsed = {}
|
||
|
||
# A Cloudflare block is not a pipeline error and reads nothing like one,
|
||
# so say what it actually is rather than showing the artist a wall of
|
||
# JSON about browser signatures.
|
||
if parsed.get("cloudflare_error") or parsed.get("error_code") == 1010:
|
||
raise RuntimeError(
|
||
"Blocked by Cloudflare before reaching the server (error %s). "
|
||
"The studio's firewall needs to allow this add-on — ask the pipeline TD."
|
||
% parsed.get("error_code", e.code)
|
||
)
|
||
|
||
message = parsed.get("error", body) if parsed else body
|
||
raise RuntimeError("Server said %s: %s" % (e.code, message))
|
||
except urllib.error.URLError as e:
|
||
raise RuntimeError("Could not reach %s (%s)" % (url, e.reason))
|
||
|
||
|
||
_project_cache = []
|
||
|
||
# Blender does not copy the strings an EnumProperty callback returns — Python
|
||
# must keep a reference to them or the UI renders freed memory, which shows up
|
||
# as garbled labels rather than as a crash. So the items are built once when the
|
||
# project list changes and held here; the callback only ever returns this list.
|
||
_enum_items = [("", "Refresh projects...", "")]
|
||
|
||
|
||
def rebuild_enum_items():
|
||
global _enum_items
|
||
if _project_cache:
|
||
_enum_items = [
|
||
(p["code"], "%s - %s" % (p["code"], p["name"]), "")
|
||
for p in _project_cache
|
||
]
|
||
else:
|
||
_enum_items = [("", "Refresh projects...", "")]
|
||
return _enum_items
|
||
|
||
|
||
def project_items(self, context):
|
||
"""Called on every redraw, so it does no work and allocates nothing."""
|
||
return _enum_items
|
||
|
||
|
||
# ── Scene → job ───────────────────────────────────────────────────────────────
|
||
|
||
def scene_summary(scene):
|
||
"""
|
||
Everything the farm needs, read from the file as saved. The .blend is
|
||
expected to be ready to render, so nothing here changes settings — it only
|
||
reports them.
|
||
"""
|
||
return {
|
||
"blendFile": bpy.data.filepath.replace("\\", "/"),
|
||
"scene": scene.name,
|
||
"frameStart": scene.frame_start,
|
||
"frameEnd": scene.frame_end,
|
||
"outputPath": bpy.path.abspath(scene.render.filepath).replace("\\", "/"),
|
||
"engine": scene.render.engine,
|
||
"blenderVersion": bpy.app.version_string,
|
||
}
|
||
|
||
|
||
class VFXR_OT_refresh_projects(Operator):
|
||
bl_idname = "vfxreview.refresh_projects"
|
||
bl_label = "Refresh Projects"
|
||
bl_description = "Load the project list from the server"
|
||
|
||
def execute(self, context):
|
||
global _project_cache
|
||
try:
|
||
data = api_request("/api/ext/projects")
|
||
except RuntimeError as err:
|
||
self.report({"ERROR"}, str(err))
|
||
return {"CANCELLED"}
|
||
|
||
_project_cache = data.get("projects", [])
|
||
rebuild_enum_items()
|
||
self.report({"INFO"}, "%d project(s) loaded" % len(_project_cache))
|
||
return {"FINISHED"}
|
||
|
||
|
||
class VFXR_OT_submit(Operator):
|
||
bl_idname = "vfxreview.submit"
|
||
bl_label = "Submit to Render Farm"
|
||
bl_description = "Queue this .blend on the farm instead of rendering locally"
|
||
|
||
def execute(self, context):
|
||
scene = context.scene
|
||
settings = scene.vfxreview
|
||
|
||
if not bpy.data.filepath:
|
||
self.report({"ERROR"}, "Save the .blend first — the farm opens it from disk")
|
||
return {"CANCELLED"}
|
||
if bpy.data.is_dirty:
|
||
# Unsaved changes would render the previous state without warning.
|
||
self.report({"ERROR"}, "Unsaved changes — save the file before submitting")
|
||
return {"CANCELLED"}
|
||
if not settings.project_code:
|
||
self.report({"ERROR"}, "Pick a project (Refresh Projects first)")
|
||
return {"CANCELLED"}
|
||
|
||
manifest = scene_summary(scene)
|
||
if manifest["frameEnd"] < manifest["frameStart"]:
|
||
self.report({"ERROR"}, "Frame end is before frame start")
|
||
return {"CANCELLED"}
|
||
|
||
label = settings.label.strip() or os.path.splitext(os.path.basename(bpy.data.filepath))[0]
|
||
|
||
try:
|
||
result = api_request(
|
||
"/api/ext/render/blender",
|
||
{
|
||
"projectCode": settings.project_code,
|
||
"label": label,
|
||
"manifest": manifest,
|
||
"submittedByEmail": prefs().artist_email or None,
|
||
"urgent": settings.urgent,
|
||
},
|
||
method="POST",
|
||
)
|
||
except RuntimeError as err:
|
||
self.report({"ERROR"}, str(err))
|
||
return {"CANCELLED"}
|
||
|
||
job = result.get("job", {})
|
||
self.report(
|
||
{"INFO"},
|
||
"Queued '%s' — %d frames%s" % (label, job.get("frames", 0), " (urgent)" if settings.urgent else ""),
|
||
)
|
||
return {"FINISHED"}
|
||
|
||
|
||
class VFXRSceneSettings(bpy.types.PropertyGroup):
|
||
project_code: EnumProperty(name="Project", items=project_items)
|
||
label: StringProperty(
|
||
name="Label",
|
||
description="Shown in the render queue. Defaults to the .blend filename",
|
||
)
|
||
urgent: BoolProperty(
|
||
name="Urgent",
|
||
description="Claimable outside the render window, ahead of normal jobs",
|
||
default=False,
|
||
)
|
||
|
||
|
||
# ── UI ────────────────────────────────────────────────────────────────────────
|
||
|
||
class VFXR_PT_submit(Panel):
|
||
bl_label = "VFXReview"
|
||
bl_space_type = "PROPERTIES"
|
||
bl_region_type = "WINDOW"
|
||
bl_context = "output"
|
||
|
||
def draw(self, context):
|
||
scene = context.scene
|
||
settings = scene.vfxreview
|
||
col = self.layout.column()
|
||
|
||
row = col.row(align=True)
|
||
row.prop(settings, "project_code", text="")
|
||
row.operator("vfxreview.refresh_projects", text="", icon="FILE_REFRESH")
|
||
|
||
col.prop(settings, "label")
|
||
col.prop(settings, "urgent")
|
||
|
||
box = col.box()
|
||
box.label(text="Renders as saved:", icon="INFO")
|
||
box.label(text="Frames %d–%d (%d)" % (
|
||
scene.frame_start, scene.frame_end, scene.frame_end - scene.frame_start + 1))
|
||
box.label(text="Engine: %s" % scene.render.engine)
|
||
output = bpy.path.abspath(scene.render.filepath)
|
||
box.label(text="Output: %s" % (output[-48:] if len(output) > 48 else output))
|
||
|
||
if bpy.data.is_dirty or not bpy.data.filepath:
|
||
warn = col.row()
|
||
warn.alert = True
|
||
warn.label(text="Save the file before submitting", icon="ERROR")
|
||
|
||
col.separator()
|
||
col.operator("vfxreview.submit", icon="RENDER_ANIMATION")
|
||
|
||
|
||
classes = (
|
||
VFXRPreferences,
|
||
VFXRSceneSettings,
|
||
VFXR_OT_refresh_projects,
|
||
VFXR_OT_submit,
|
||
VFXR_PT_submit,
|
||
)
|
||
|
||
|
||
def register():
|
||
for cls in classes:
|
||
bpy.utils.register_class(cls)
|
||
bpy.types.Scene.vfxreview = bpy.props.PointerProperty(type=VFXRSceneSettings)
|
||
|
||
|
||
def unregister():
|
||
del bpy.types.Scene.vfxreview
|
||
for cls in reversed(classes):
|
||
bpy.utils.unregister_class(cls)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
register()
|