From 70cf45ead470df226309ea3f3b588a37868a0631 Mon Sep 17 00:00:00 2001
From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:53:19 +0200
Subject: [PATCH] feat(pipeline): standalone Blender renders on the same queue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Blender work is not a shot submission: no Export, no version, no
validation, QC or delivery. It is "Ctrl+F12, but on the farm", so it is
modelled as what it is — a queue entry and nothing more.
That turned out to need very little. Claim, progress, fail and
complete-render already tolerated a job with no Export behind them, so a
Blender job reuses the whole lifecycle; RenderJob gains projectId, label
and submittedBy so a job with no shot code still has something to show.
Capability gating is new and was already missing: the claim now filters
job types by the machine's reported tools. Without it a workstation
without Blender would claim Blender work and fail it repeatedly, burning
the attempts while a machine that could have rendered it sat idle. The
worker asks for Blender jobs unconditionally and the server refuses them
— trusting the worker to self-select would have left the same hole.
The runner treats a clean exit with no saved frames as a failure:
Blender exits 0 when a frame range is empty or an output path is
unwritable, and a job that silently rendered nothing is the worst
outcome available.
Standalone jobs auto-requeue on a transient failure like any other work;
delivery builds are deliberately excluded, since a half-written package
wants a human first.
Adds the Blender add-on (BlenderPanel/) and its docs. The add-on refuses
to submit an unsaved file — the farm opens the .blend from disk, so
unsaved changes would render the previous state without warning. Its
payload is pinned by a contract test, since add-on and server only ever
meet over JSON.
Worker 1.5.0. Adds migration 20260815000000_blender_render_jobs.
Co-Authored-By: Claude Opus 5
---
BlenderPanel/README.md | 88 ++++++
BlenderPanel/vfxreview_submit.py | 251 +++++++++++++++++
README.md | 1 +
RenderWorker/README.md | 18 ++
RenderWorker/VFXReviewWorker/ApiClient.cs | 15 +-
.../VFXReviewWorker/BlenderProgressParser.cs | 68 +++++
RenderWorker/VFXReviewWorker/BlenderRunner.cs | 260 ++++++++++++++++++
RenderWorker/VFXReviewWorker/Dtos.cs | 29 ++
RenderWorker/VFXReviewWorker/JobExecutor.cs | 78 +++++-
RenderWorker/VFXReviewWorker/Preflight.cs | 5 +
RenderWorker/VFXReviewWorker/Program.cs | 1 +
.../VFXReviewWorker/VFXReviewWorker.csproj | 2 +-
RenderWorker/VFXReviewWorker/WorkerOptions.cs | 6 +
RenderWorker/VFXReviewWorker/WorkerService.cs | 6 +-
.../pipeline/PipelineQueueClient.tsx | 124 +++++++++
app/api/ext/render/blender/route.ts | 36 +++
app/api/render/blender/[jobId]/route.ts | 28 ++
app/api/render/blender/route.ts | 24 ++
lib/render-pipeline/blender.test.ts | 50 ++++
lib/render-pipeline/blender.ts | 189 +++++++++++++
lib/render-pipeline/jobs.ts | 40 ++-
lib/render-pipeline/machines.ts | 13 +
.../migration.sql | 12 +
prisma/schema.prisma | 12 +
24 files changed, 1347 insertions(+), 9 deletions(-)
create mode 100644 BlenderPanel/README.md
create mode 100644 BlenderPanel/vfxreview_submit.py
create mode 100644 RenderWorker/VFXReviewWorker/BlenderProgressParser.cs
create mode 100644 RenderWorker/VFXReviewWorker/BlenderRunner.cs
create mode 100644 app/api/ext/render/blender/route.ts
create mode 100644 app/api/render/blender/[jobId]/route.ts
create mode 100644 app/api/render/blender/route.ts
create mode 100644 lib/render-pipeline/blender.test.ts
create mode 100644 lib/render-pipeline/blender.ts
create mode 100644 prisma/migrations/20260815000000_blender_render_jobs/migration.sql
diff --git a/BlenderPanel/README.md b/BlenderPanel/README.md
new file mode 100644
index 0000000..50b1137
--- /dev/null
+++ b/BlenderPanel/README.md
@@ -0,0 +1,88 @@
+# VFXReview — Blender Submit
+
+A Blender add-on that queues the open `.blend` on the studio render farm instead
+of rendering it locally. Ctrl+F12, but on the farm.
+
+---
+
+## What a Blender job is (and is not)
+
+Deliberately the simplest thing in the pipeline: **a queue entry and nothing
+more**. It has no Export, takes no shot version, and is never validated, QC'd or
+delivered. The `.blend` is expected to be ready to render — output path, format
+and frame range already set — and the farm just runs it.
+
+That is the whole difference from an After Effects export, which is a shot
+submission and carries the full lifecycle behind it.
+
+---
+
+## Install
+
+1. **Edit → Preferences → Add-ons → Install…** and pick `vfxreview_submit.py`.
+2. Enable **Render: VFXReview — Submit Render**.
+3. Expand its preferences and set:
+
+| Setting | Value |
+|---|---|
+| **Server URL** | `https://review.twotalesvfx.com` |
+| **API Key** | matches `API_SECRET_KEY` on the server (same key the AE panel uses) |
+| **Artist Email** | recorded against the job so the queue shows who submitted it |
+
+The panel appears under **Properties → Output → VFXReview**.
+
+---
+
+## Submitting
+
+1. **Refresh Projects** (the circular arrow) and pick the project.
+2. Optionally set a **Label** — defaults to the `.blend` filename, and is what
+ the render queue shows, since there is no shot code.
+3. **Urgent** makes the job claimable outside the render window, ahead of normal
+ work.
+4. **Submit to Render Farm**.
+
+The panel shows the frame range, engine and output path it will send, read from
+the file as saved. Nothing is changed on submission.
+
+**The file must be saved first.** The farm opens the `.blend` from disk, so
+unsaved changes would render the previous state without warning — the add-on
+refuses to submit a dirty file rather than let that happen.
+
+Track the job on the web app under **Pipeline → Blender renders**: live
+progress, current frame, ETA and the machine running it, with cancel and requeue.
+
+---
+
+## Which machines can render it
+
+Only workers with `blenderPath` set in their `config.json` and the executable
+present. The worker reports Blender at registration and the server hands
+`BLENDER_RENDER` jobs only to machines that have it — a machine without Blender
+can never claim work it could only fail.
+
+Verify on a worker with:
+
+```powershell
+.\VFXReviewWorker.exe --check
+```
+
+Blender is listed as optional there: a machine without it stays perfectly valid
+for After Effects work.
+
+---
+
+## Notes
+
+**Paths must resolve on the render machine.** The `.blend` path and its output
+path are submitted as the artist sees them; the worker applies its own
+`pathMappings` before opening the file. A path on a drive letter the render
+account cannot see is the most common reason a job fails immediately.
+
+**Renders as saved.** The add-on sends the scene's own settings. The manifest
+supports overrides (samples, resolution %, device, engine) for future use, but
+nothing in the UI sets them today — one place for render settings is the point.
+
+**Exit code 0 with no frames is treated as a failure.** Blender exits cleanly
+when a frame range is empty or an output path is unwritable, so the worker
+checks that frames were actually saved before reporting success.
diff --git a/BlenderPanel/vfxreview_submit.py b/BlenderPanel/vfxreview_submit.py
new file mode 100644
index 0000000..d54a39e
--- /dev/null
+++ b/BlenderPanel/vfxreview_submit.py
@@ -0,0 +1,251 @@
+bl_info = {
+ "name": "VFXReview — Submit Render",
+ "author": "Two Tales",
+ "version": (1, 0, 0),
+ "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
+
+
+# ── 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")
+ 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:
+ message = json.loads(body).get("error", body)
+ except Exception:
+ message = 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()
diff --git a/README.md b/README.md
index 593350e..a2ba230 100644
--- a/README.md
+++ b/README.md
@@ -174,6 +174,7 @@ prisma/ — schema.prisma, seed.ts
| [DELIVERY_WORKFLOW.md](DELIVERY_WORKFLOW.md) | Building and shipping delivery packages (how-to) |
| [VFXReviewConnector.md](VFXReviewConnector.md) | After Effects panel — setup, Shot Builder, QC, diagnostics |
| [RenderWorker/README.md](RenderWorker/README.md) | Render worker service — packaging, install, validation, delivery builds |
+| [BlenderPanel/README.md](BlenderPanel/README.md) | Blender add-on — queueing a .blend on the farm |
| [EXT_API_REFERENCE.md](EXT_API_REFERENCE.md) | External API (`/api/ext/*`) used by the panel and workers |
| [Shot task workflow.md](Shot%20task%20workflow.md) | Shot and task lifecycle in the web app |
diff --git a/RenderWorker/README.md b/RenderWorker/README.md
index 013c09d..30460aa 100644
--- a/RenderWorker/README.md
+++ b/RenderWorker/README.md
@@ -89,6 +89,23 @@ Server-side switches (SystemConfig, no worker redeploy): `validation.enabled`,
`validation.sampleEvery`, `validation.handleFrames`, and `validation.overrides` for per-check
severity, e.g. `{"exr_type":"warn"}`.
+## Blender renders
+
+The worker also claims `BLENDER_RENDER` jobs: standalone renders submitted from
+the Blender add-on, with no Export behind them — nothing to validate, preview or
+QC. It runs `blender -b file.blend -s N -e M -a`, parses `Fra:`/`Saved:` output
+for progress, and reports complete or failed like any other job.
+
+Set `blenderPath` in `config.json` to make a machine eligible. The version is
+reported at registration and **the server only hands Blender jobs to machines
+that have it**, so a workstation without Blender can never claim work it could
+only fail. A machine without it stays perfectly valid for After Effects work.
+
+Blender exits 0 when a frame range is empty or an output path is unwritable, so
+a run that saves no frames is reported as a failure rather than a success.
+
+See [BlenderPanel/README.md](../BlenderPanel/README.md).
+
## Delivery builds
The same service also claims `DELIVERY_BUILD` jobs: creating a dated package
@@ -174,6 +191,7 @@ Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8):
"aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe",
"aeVersion": "24.3",
"ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe",
+ "blenderPath": "C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe",
"plateSearchRoots": ["V:/_FOOTAGE/UNG"],
"pathMappings": [
{ "from": "//SAN/", "to": "S:/" }
diff --git a/RenderWorker/VFXReviewWorker/ApiClient.cs b/RenderWorker/VFXReviewWorker/ApiClient.cs
index b6a0f68..76051ba 100644
--- a/RenderWorker/VFXReviewWorker/ApiClient.cs
+++ b/RenderWorker/VFXReviewWorker/ApiClient.cs
@@ -37,7 +37,20 @@ public sealed class ApiClient
hostname = Dns.GetHostName(),
workerVersion = o.WorkerVersion,
aeVersion = o.AeVersion,
- capabilities = new { maxConcurrentJobs = 1, tools = new { ffmpeg = o.FfmpegPath != null, oiiotool = o.OiiotoolPath != null } },
+ // The server hands out BLENDER_RENDER work on the strength of the
+ // blender entry, so it reports the resolved path rather than a bare
+ // flag: a machine claiming Blender jobs it cannot run is worse than
+ // one that never claims them.
+ capabilities = new
+ {
+ maxConcurrentJobs = 1,
+ tools = new
+ {
+ ffmpeg = o.FfmpegPath != null,
+ oiiotool = o.OiiotoolPath != null,
+ blender = !string.IsNullOrWhiteSpace(o.BlenderPath) && File.Exists(o.BlenderPath) ? o.BlenderPath : null,
+ },
+ },
}, Json, ct);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync(Json, ct))!;
diff --git a/RenderWorker/VFXReviewWorker/BlenderProgressParser.cs b/RenderWorker/VFXReviewWorker/BlenderProgressParser.cs
new file mode 100644
index 0000000..a79a82b
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker/BlenderProgressParser.cs
@@ -0,0 +1,68 @@
+using System.Text.RegularExpressions;
+
+namespace VFXReviewWorker;
+
+///
+/// Parses `blender -b` stdout. Blender reports the frame it is working on in
+/// its status lines ("Fra:1042 Mem:512.00M ...") and confirms each written file
+/// with "Saved: '...'". The saved lines are the reliable signal — a frame can
+/// be reported for a long time while it renders — so progress counts those and
+/// falls back to Fra: for a first sign of life.
+///
+public static partial class BlenderProgressParser
+{
+ [GeneratedRegex(@"^Fra:(\d+)", RegexOptions.Compiled)]
+ private static partial Regex FrameLine();
+
+ [GeneratedRegex(@"^Saved:\s*'(.+)'", RegexOptions.Compiled)]
+ private static partial Regex SavedLine();
+
+ [GeneratedRegex(@"^(Error|Warning): |^Error: ", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
+ private static partial Regex ErrorLine();
+
+ ///
+ /// Failures that will repeat identically on another machine — a missing
+ /// file, an unknown scene, an unwritable output. Retrying these burns the
+ /// queue for nothing; a human has to fix the .blend or the path.
+ ///
+ private static readonly string[] NonRetryablePatterns =
+ {
+ "cannot read file",
+ "file not found",
+ "unable to open",
+ "is not a blend file",
+ "failed to read blend file",
+ "unknown scene",
+ "scene not found",
+ "unable to create directory",
+ "cannot write file",
+ "no camera",
+ "python script fail",
+ "unknown argument",
+ };
+
+ /// The frame Blender is currently working on, or null.
+ public static int? ParseFrame(string line)
+ {
+ var m = FrameLine().Match(line);
+ return m.Success && int.TryParse(m.Groups[1].Value, out var f) ? f : null;
+ }
+
+ /// The path of a frame Blender has just written, or null.
+ public static string? ParseSaved(string line)
+ {
+ var m = SavedLine().Match(line);
+ return m.Success ? m.Groups[1].Value : null;
+ }
+
+ public static bool IsErrorLine(string line) => ErrorLine().IsMatch(line);
+
+ public static bool IsNonRetryableError(string line)
+ {
+ foreach (var p in NonRetryablePatterns)
+ {
+ if (line.Contains(p, StringComparison.OrdinalIgnoreCase)) return true;
+ }
+ return false;
+ }
+}
diff --git a/RenderWorker/VFXReviewWorker/BlenderRunner.cs b/RenderWorker/VFXReviewWorker/BlenderRunner.cs
new file mode 100644
index 0000000..512f3b9
--- /dev/null
+++ b/RenderWorker/VFXReviewWorker/BlenderRunner.cs
@@ -0,0 +1,260 @@
+using System.Diagnostics;
+using System.Text;
+using Microsoft.Extensions.Logging;
+
+namespace VFXReviewWorker;
+
+///
+/// Standalone Blender renders: `blender -b file.blend -s N -e M -a`.
+///
+/// The .blend is expected to be ready to render — output path, format and
+/// frame range already set by whoever built it. Overrides are only applied
+/// when the submission asked for them, so this stays "Ctrl+F12 on the farm"
+/// rather than a second place where render settings can drift.
+///
+public sealed class BlenderRunner
+{
+ private readonly WorkerOptions _options;
+ private readonly ApiClient _api;
+ private readonly ILogger _log;
+
+ public BlenderRunner(WorkerOptions options, ApiClient api, ILogger log)
+ {
+ _options = options;
+ _api = api;
+ _log = log;
+ }
+
+ public async Task RunAsync(
+ ClaimedJob job,
+ BlenderManifest m,
+ string machineId,
+ ServerConfig config,
+ CancellationToken cancelJob,
+ CancellationToken shutdown)
+ {
+ var mapper = new PathMapper(_options.PathMappings);
+ var blendLocal = mapper.Map(m.BlendFile);
+ var totalFrames = m.TotalFrames;
+
+ if (!File.Exists(blendLocal))
+ {
+ return new RenderResult(false, -1, Retryable: false,
+ $"Blend file not found on this machine: {blendLocal}", "", "", 0);
+ }
+
+ // Argument order matters to Blender: everything before -a configures the
+ // render, and -a must come last or it renders with the old settings.
+ var args = new List { "-b", blendLocal };
+ if (!string.IsNullOrWhiteSpace(m.Scene)) { args.Add("-S"); args.Add(m.Scene!); }
+ if (!string.IsNullOrWhiteSpace(m.OutputPath))
+ {
+ args.Add("-o");
+ args.Add(mapper.Map(m.OutputPath!));
+ }
+ if (!string.IsNullOrWhiteSpace(m.Engine)) { args.Add("-E"); args.Add(m.Engine!); }
+
+ var pythonOverrides = BuildOverrideExpression(m);
+ if (pythonOverrides is not null) { args.Add("--python-expr"); args.Add(pythonOverrides); }
+
+ args.Add("-s"); args.Add(m.FrameStart.ToString());
+ args.Add("-e"); args.Add(m.FrameEnd.ToString());
+ args.Add("-a");
+
+ _log.LogInformation("Rendering {Label}: {Blend} frames {Start}-{End}",
+ job.Label ?? job.Id, blendLocal, m.FrameStart, m.FrameEnd);
+
+ return await ExecuteAsync(job.Id, machineId, args, totalFrames, m.FrameStart, config,
+ $"blender_{job.Id}.log", cancelJob, shutdown);
+ }
+
+ ///
+ /// Settings Blender has no command-line flag for. Returns null when the
+ /// submission asked for no overrides, so the file renders exactly as saved.
+ ///
+ private static string? BuildOverrideExpression(BlenderManifest m)
+ {
+ var parts = new List();
+ if (m.Samples is int samples)
+ {
+ parts.Add($"getattr(S.cycles, 'samples', None) and setattr(S.cycles, 'samples', {samples})");
+ }
+ if (m.ResolutionPercent is int pct)
+ {
+ parts.Add($"setattr(S.render, 'resolution_percentage', {pct})");
+ }
+ if (!string.IsNullOrWhiteSpace(m.Device))
+ {
+ var deviceValue = m.Device!.Equals("GPU", StringComparison.OrdinalIgnoreCase) ? "GPU" : "CPU";
+ parts.Add($"hasattr(S, 'cycles') and setattr(S.cycles, 'device', '{deviceValue}')");
+ }
+ if (parts.Count == 0) return null;
+
+ return "import bpy; S = bpy.context.scene; " + string.Join("; ", parts);
+ }
+
+ private async Task ExecuteAsync(
+ string jobId,
+ string machineId,
+ List args,
+ int totalFrames,
+ int frameStart,
+ ServerConfig config,
+ string logFileName,
+ CancellationToken cancelJob,
+ CancellationToken shutdown)
+ {
+ var fullLogPath = Path.Combine(WorkerOptions.LogsDir, logFileName);
+ Directory.CreateDirectory(WorkerOptions.LogsDir);
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = _options.BlenderPath,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+ foreach (var a in args) psi.ArgumentList.Add(a);
+
+ var tail = new LogTail(200);
+ var eta = new EtaCalculator();
+ var sw = Stopwatch.StartNew();
+ var lastProgressAt = DateTimeOffset.UtcNow;
+ var lastReportAt = DateTimeOffset.MinValue;
+ var framesSaved = 0;
+ var currentFrame = frameStart;
+ string? firstError = null;
+ var nonRetryable = false;
+ var cancelledByServer = false;
+
+ using var process = new Process { StartInfo = psi };
+ await using var fullLog = new StreamWriter(fullLogPath, append: false, Encoding.UTF8);
+ var logLock = new object();
+
+ void HandleLine(string? line, bool isStderr)
+ {
+ if (line is null) return;
+ lock (logLock)
+ {
+ fullLog.WriteLine(line);
+ tail.Add(line);
+ }
+
+ var frame = BlenderProgressParser.ParseFrame(line);
+ if (frame is not null)
+ {
+ // A frame line only proves Blender is alive; a long frame emits
+ // many of them, so this feeds the stall watchdog, not progress.
+ currentFrame = frame.Value;
+ lastProgressAt = DateTimeOffset.UtcNow;
+ }
+ if (BlenderProgressParser.ParseSaved(line) is not null)
+ {
+ framesSaved++;
+ lastProgressAt = DateTimeOffset.UtcNow;
+ eta.RecordFrame(lastProgressAt);
+ }
+ if ((isStderr || BlenderProgressParser.IsErrorLine(line)) && !string.IsNullOrWhiteSpace(line))
+ {
+ if (BlenderProgressParser.IsErrorLine(line)) firstError ??= line.Trim();
+ if (BlenderProgressParser.IsNonRetryableError(line)) nonRetryable = true;
+ }
+ }
+
+ process.OutputDataReceived += (_, e) => HandleLine(e.Data, isStderr: false);
+ process.ErrorDataReceived += (_, e) => HandleLine(e.Data, isStderr: true);
+
+ try
+ {
+ process.Start();
+ }
+ catch (Exception ex)
+ {
+ return new RenderResult(false, -1, Retryable: false,
+ $"Failed to launch Blender at {_options.BlenderPath}: {ex.Message}",
+ tail.ToString(), fullLogPath, 0);
+ }
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ while (!process.HasExited)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None);
+
+ if (cancelJob.IsCancellationRequested || shutdown.IsCancellationRequested)
+ {
+ _log.LogWarning("Cancellation requested — killing Blender tree for job {JobId}", jobId);
+ KillTree(process);
+ cancelledByServer = cancelJob.IsCancellationRequested;
+ break;
+ }
+
+ if ((DateTimeOffset.UtcNow - lastProgressAt).TotalSeconds > config.StallTimeoutSeconds)
+ {
+ _log.LogError("Blender stalled ({Sec}s without output) — killing job {JobId}", config.StallTimeoutSeconds, jobId);
+ KillTree(process);
+ await WaitForExitSafe(process);
+ return new RenderResult(false, -2, Retryable: true,
+ $"Blender produced no output for {config.StallTimeoutSeconds}s (stall timeout)",
+ tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
+ }
+
+ if ((DateTimeOffset.UtcNow - lastReportAt).TotalSeconds >= 10)
+ {
+ lastReportAt = DateTimeOffset.UtcNow;
+ var progress = totalFrames > 0 ? Math.Min(1.0, (double)framesSaved / totalFrames) : 0;
+ var resp = await _api.ProgressAsync(jobId, machineId, progress, currentFrame, totalFrames,
+ eta.EstimateSeconds(totalFrames - framesSaved), tail.ToString(), shutdown);
+ if (resp?.CancelRequested == true)
+ {
+ _log.LogWarning("Server requested cancel for job {JobId}", jobId);
+ KillTree(process);
+ cancelledByServer = true;
+ break;
+ }
+ }
+ }
+
+ await WaitForExitSafe(process);
+ sw.Stop();
+ lock (logLock) { fullLog.Flush(); }
+
+ if (cancelledByServer)
+ {
+ return new RenderResult(false, -3, Retryable: false, "Cancelled", tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
+ }
+
+ var exitCode = process.ExitCode;
+ if (exitCode != 0)
+ {
+ return new RenderResult(false, exitCode, Retryable: !nonRetryable,
+ firstError ?? $"Blender exited with code {exitCode}",
+ tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
+ }
+
+ // Blender exits 0 having rendered nothing if the range is empty or the
+ // output path is unwritable, so a silent success is treated as failure.
+ if (framesSaved == 0)
+ {
+ return new RenderResult(false, 0, Retryable: false,
+ "Blender exited cleanly but saved no frames — check the output path and frame range in the .blend",
+ tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
+ }
+
+ _log.LogInformation("Job {JobId}: {Saved}/{Total} frames in {Sec:F0}s", jobId, framesSaved, totalFrames, sw.Elapsed.TotalSeconds);
+ return new RenderResult(true, 0, true, null, tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
+ }
+
+ private static void KillTree(Process process)
+ {
+ try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { }
+ }
+
+ private static async Task WaitForExitSafe(Process process)
+ {
+ try { await process.WaitForExitAsync(); } catch { }
+ }
+}
diff --git a/RenderWorker/VFXReviewWorker/Dtos.cs b/RenderWorker/VFXReviewWorker/Dtos.cs
index 04f0acc..8143cc7 100644
--- a/RenderWorker/VFXReviewWorker/Dtos.cs
+++ b/RenderWorker/VFXReviewWorker/Dtos.cs
@@ -49,6 +49,9 @@ public sealed class ClaimedJob
public int Attempt { get; set; }
public int MaxAttempts { get; set; }
public string? ExportId { get; set; }
+ public string? DeliveryId { get; set; }
+ /// Set for jobs with no shot behind them; shown instead of a shot code.
+ public string? Label { get; set; }
public DateTimeOffset? LeaseExpiresAt { get; set; }
/// Manifest shape depends on the job type — deserialize per stage.
@@ -65,6 +68,32 @@ public sealed class ClaimedJob
public DeliveryManifest AsDeliveryManifest() =>
Manifest.Deserialize(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a delivery manifest");
+
+ public BlenderManifest AsBlenderManifest() =>
+ Manifest.Deserialize(WorkerOptions.JsonOpts)
+ ?? throw new InvalidOperationException("Job manifest is not a Blender manifest");
+}
+
+///
+/// A standalone Blender render. Overrides are all optional: an unset value
+/// leaves whatever the .blend was saved with, which is the point — the file is
+/// expected to be ready to render.
+///
+public sealed class BlenderManifest
+{
+ public string Stage { get; set; } = "BLENDER";
+ public string BlendFile { get; set; } = "";
+ public string? Scene { get; set; }
+ public int FrameStart { get; set; }
+ public int FrameEnd { get; set; }
+ public string? OutputPath { get; set; }
+ public string? Engine { get; set; }
+ public string? Device { get; set; }
+ public int? Samples { get; set; }
+ public int? ResolutionPercent { get; set; }
+ public string? BlenderVersion { get; set; }
+
+ public int TotalFrames => FrameEnd - FrameStart + 1;
}
/// DELIVERY_BUILD manifest (server-built, RenderPipeline2 §11).
diff --git a/RenderWorker/VFXReviewWorker/JobExecutor.cs b/RenderWorker/VFXReviewWorker/JobExecutor.cs
index 9556200..b5a8d67 100644
--- a/RenderWorker/VFXReviewWorker/JobExecutor.cs
+++ b/RenderWorker/VFXReviewWorker/JobExecutor.cs
@@ -16,11 +16,12 @@ public sealed class JobExecutor
private readonly PreviewStage _preview;
private readonly ValidationStage _validation;
private readonly DeliveryStage _delivery;
+ private readonly BlenderRunner _blender;
private readonly ReportSpooler _spooler;
private readonly ILogger _log;
public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview,
- ValidationStage validation, DeliveryStage delivery, ReportSpooler spooler, ILogger log)
+ ValidationStage validation, DeliveryStage delivery, BlenderRunner blender, ReportSpooler spooler, ILogger log)
{
_options = options;
_api = api;
@@ -28,6 +29,7 @@ public sealed class JobExecutor
_preview = preview;
_validation = validation;
_delivery = delivery;
+ _blender = blender;
_spooler = spooler;
_log = log;
}
@@ -46,6 +48,12 @@ public sealed class JobExecutor
return;
}
+ if (job.Type == "BLENDER_RENDER")
+ {
+ await RunBlenderAsync(job, machineId, config, cancelJob, shutdown);
+ return;
+ }
+
var manifest = job.AsRenderManifest();
var mapper = new PathMapper(_options.PathMappings);
var outputDirLocal = mapper.Map(manifest.OutputDir);
@@ -169,6 +177,74 @@ public sealed class JobExecutor
}
}
+ ///
+ /// BLENDER_RENDER job: a standalone render with no Export behind it, so
+ /// there is nothing to validate, preview or QC — it finishes at
+ /// complete-render and that is the whole lifecycle.
+ ///
+ private async Task RunBlenderAsync(ClaimedJob job, string machineId, ServerConfig config,
+ CancellationToken cancelJob, CancellationToken shutdown)
+ {
+ var manifest = job.AsBlenderManifest();
+
+ WriteState(new CurrentJobState
+ {
+ JobId = job.Id,
+ ExportId = null,
+ MachineId = machineId,
+ OutputDirLocal = manifest.OutputPath ?? "",
+ ClaimedAt = DateTimeOffset.UtcNow,
+ });
+
+ try
+ {
+ var result = await _blender.RunAsync(job, manifest, machineId, config, cancelJob, shutdown);
+ var logKey = string.IsNullOrEmpty(result.FullLogPath)
+ ? null
+ : await _api.UploadLogAsync(job.Id, machineId, result.FullLogPath, shutdown);
+
+ if (result.Success)
+ {
+ _log.LogInformation("Blender job {JobId} complete: {Frames} frames in {Sec:F0}s",
+ job.Id, manifest.TotalFrames, result.RenderSeconds);
+ _spooler.Enqueue("complete-render", job.Id, new Dictionary
+ {
+ ["machineId"] = machineId,
+ ["renderSeconds"] = (int)result.RenderSeconds,
+ ["logFileKey"] = logKey,
+ ["logTail"] = result.LogTail,
+ });
+ return;
+ }
+
+ _spooler.Enqueue("fail", job.Id, new Dictionary
+ {
+ ["machineId"] = machineId,
+ ["stage"] = "BLENDER",
+ ["exitCode"] = result.ExitCode,
+ ["errorMessage"] = result.ErrorMessage,
+ ["logTail"] = result.LogTail,
+ ["logFileKey"] = logKey,
+ ["retryable"] = result.ErrorMessage == "Cancelled" ? false : result.Retryable,
+ });
+ }
+ catch (Exception ex)
+ {
+ _log.LogError(ex, "Unexpected Blender error for job {JobId}", job.Id);
+ _spooler.Enqueue("fail", job.Id, new Dictionary
+ {
+ ["machineId"] = machineId,
+ ["stage"] = "BLENDER",
+ ["errorMessage"] = $"Worker exception: {ex.Message}",
+ ["retryable"] = true,
+ });
+ }
+ finally
+ {
+ ClearState();
+ }
+ }
+
///
/// DELIVERY_BUILD job: build the dated package folder from already-QC'd
/// exports. No AE involved — folders, links and verification only.
diff --git a/RenderWorker/VFXReviewWorker/Preflight.cs b/RenderWorker/VFXReviewWorker/Preflight.cs
index 9279836..16721eb 100644
--- a/RenderWorker/VFXReviewWorker/Preflight.cs
+++ b/RenderWorker/VFXReviewWorker/Preflight.cs
@@ -38,6 +38,11 @@ public static class Preflight
Console.WriteLine();
ok &= Report("aerender.exe", options.AerenderPath, File.Exists(options.AerenderPath));
+ // Not required: a machine without Blender simply never claims Blender
+ // jobs, so this reports rather than fails.
+ Report("blender.exe", options.BlenderPath ?? "(not configured)",
+ !string.IsNullOrWhiteSpace(options.BlenderPath) && File.Exists(options.BlenderPath),
+ "needed only for Blender render jobs");
var afterFx = options.ResolveAfterFxPath();
ok &= Report("AfterFX.com", afterFx, File.Exists(afterFx),
"needed for preview builds only");
diff --git a/RenderWorker/VFXReviewWorker/Program.cs b/RenderWorker/VFXReviewWorker/Program.cs
index e09404b..2758040 100644
--- a/RenderWorker/VFXReviewWorker/Program.cs
+++ b/RenderWorker/VFXReviewWorker/Program.cs
@@ -41,6 +41,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
diff --git a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj
index f10fbce..0371ffd 100644
--- a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj
+++ b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj
@@ -15,7 +15,7 @@
every change that gets published. publish.ps1 appends the git SHA via
SourceRevisionId, giving "1.1.0+feb1c7b".
-->
- 1.4.0
+ 1.5.0
diff --git a/RenderWorker/VFXReviewWorker/WorkerOptions.cs b/RenderWorker/VFXReviewWorker/WorkerOptions.cs
index d0a9fe1..a87c611 100644
--- a/RenderWorker/VFXReviewWorker/WorkerOptions.cs
+++ b/RenderWorker/VFXReviewWorker/WorkerOptions.cs
@@ -20,6 +20,12 @@ public sealed class WorkerOptions
/// vfxr_build_preview.jsx — defaults to scripts\ beside the worker exe.
public string? PreviewScriptPath { get; set; }
public string? FfmpegPath { get; set; }
+ ///
+ /// blender.exe. Its presence is what makes this machine eligible for
+ /// Blender jobs: the version is reported at registration and the server
+ /// only hands BLENDER_RENDER work to machines that have it.
+ ///
+ public string? BlenderPath { get; set; }
public string? OiiotoolPath { get; set; }
public List PathMappings { get; set; } = new();
///
diff --git a/RenderWorker/VFXReviewWorker/WorkerService.cs b/RenderWorker/VFXReviewWorker/WorkerService.cs
index 63e4f1c..d5ebcfc 100644
--- a/RenderWorker/VFXReviewWorker/WorkerService.cs
+++ b/RenderWorker/VFXReviewWorker/WorkerService.cs
@@ -64,9 +64,11 @@ public sealed class WorkerService : BackgroundService
}
// Delivery builds are file work with no AE involvement, so they
// are always claimable — someone is usually waiting on the folder.
+ // Blender is asked for unconditionally; the server refuses it to
+ // machines whose capabilities do not include Blender.
var types = aeOpen
- ? new[] { "AE_RENDER", "DELIVERY_BUILD" }
- : new[] { "AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD" };
+ ? new[] { "AE_RENDER", "DELIVERY_BUILD", "BLENDER_RENDER" }
+ : new[] { "AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD", "BLENDER_RENDER" };
var job = await _api.ClaimAsync(_machineId!, types, stoppingToken);
if (job is not null)
diff --git a/app/(dashboard)/pipeline/PipelineQueueClient.tsx b/app/(dashboard)/pipeline/PipelineQueueClient.tsx
index 3b4fab6..5a03a8b 100644
--- a/app/(dashboard)/pipeline/PipelineQueueClient.tsx
+++ b/app/(dashboard)/pipeline/PipelineQueueClient.tsx
@@ -112,6 +112,12 @@ export function PipelineQueueClient({ projects }: Props) {
+
+
+
+
+
+
+ );
+}
+
+interface BlenderJobRow {
+ id: string;
+ label: string | null;
+ status: string;
+ attempt: number;
+ maxAttempts: number;
+ progress: number;
+ currentFrame: number | null;
+ totalFrames: number | null;
+ etaSeconds: number | null;
+ errorMessage: string | null;
+ machineName: string | null;
+ submittedBy: string | null;
+ projectCode: string | null;
+ blendFile: string | null;
+ frameStart: number | null;
+ frameEnd: number | null;
+ createdAt: string;
+}
+
+const BLENDER_STATUS_STYLES: Record = {
+ QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
+ CLAIMED: "bg-blue-500/15 text-blue-400 border-blue-500/30",
+ RUNNING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
+ COMPLETED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
+ FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
+ CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
+ EXPIRED: "bg-red-500/15 text-red-400 border-red-500/30",
+};
+
+/**
+ * Standalone Blender renders. They have no Export behind them, so they cannot
+ * appear in the table above — but they share the queue, and a render nobody can
+ * see is a render nobody can retry.
+ */
+function BlenderQueue({ projectId }: { projectId: string }) {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+
+ const { data } = useQuery({
+ queryKey: ["blender-jobs", projectId],
+ queryFn: async () => {
+ const qs = projectId && projectId !== "all" ? `?projectId=${encodeURIComponent(projectId)}` : "";
+ const res = await fetch(`/api/render/blender${qs}`);
+ if (!res.ok) throw new Error("Failed to load Blender jobs");
+ return res.json() as Promise<{ jobs: BlenderJobRow[] }>;
+ },
+ refetchInterval: 5000,
+ });
+
+ async function jobAction(id: string, verb: "cancel" | "retry") {
+ const res = await fetch(`/api/render/blender/${id}?action=${verb}`, { method: "POST" });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
+ }
+ queryClient.invalidateQueries({ queryKey: ["blender-jobs"] });
+ }
+
+ const jobs = data?.jobs ?? [];
+ if (jobs.length === 0) return null;
+
+ return (
+