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()