3733c0c4fa
Deploy / deploy (push) Successful in 5m59s
Submitting failed with a 403 before the request reached the server: Cloudflare error 1010, a browser-signature ban. Python's urllib identifies itself as "Python-urllib/3.x", which is exactly the scripted signature that rule targets. The AE panel was unaffected because curl sends curl/8.x, and the worker because .NET sends its own. Verified against the live zone, same request and token, only the agent changed: Python-urllib/3.11 gets 403 while VFXReviewBlender/1.0.1 gets 200 — as do curl and a browser agent. Naming the tool is also the honest thing: the studio's logs should say what is calling them, and the version travels with it. A Cloudflare block is also no longer reported to the artist as a wall of JSON about browser signatures — it says the firewall stopped the request before it reached the server, and to ask the pipeline TD. Add-on 1.0.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
9.5 KiB
Python
273 lines
9.5 KiB
Python
bl_info = {
|
||
"name": "VFXReview — Submit Render",
|
||
"author": "Two Tales",
|
||
"version": (1, 0, 1),
|
||
"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 = []
|
||
|
||
|
||
def project_items(self, context):
|
||
"""Project dropdown. Cached because Blender calls this on every redraw."""
|
||
if not _project_cache:
|
||
return [("", "Refresh projects…", "")]
|
||
return [(p["code"], "%s — %s" % (p["code"], p["name"]), "") for p in _project_cache]
|
||
|
||
|
||
# ── 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", [])
|
||
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()
|