Files
vfxreview/BlenderPanel/vfxreview_submit.py
T
twotalesanimation a18aad4373
Deploy / deploy (push) Successful in 6m6s
fix(blender): hold a reference to the project enum items
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>
2026-08-15 19:24:46 +02:00

290 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()