Files
vfxreview/RenderWorker/VFXReviewWorker/BlenderRunner.cs
T
twotalesanimation 708e8a0d8f
Deploy / deploy (push) Successful in 3m28s
fix(worker): parse Blender 5.x output so rendered frames are counted
Blender 5 logs through CLOG, prefixing every line with elapsed time and a
category ("00:02.954  render | Saved: '...'"), and reports errors as
"ERROR ..." instead of "Error: ...". The parser anchored on "Saved:" at the
start of the line, so it counted no frames from a render that wrote all of
them and the job failed as "saved no frames". Progress (Fra:) and error
detection were dead on 5.x for the same reason.

The prefix is now stripped before matching; 4.x output parses as before.
Warnings no longer count as errors: add-ons print them on every launch and
one would otherwise be reported as the reason a render failed. A run that
saves nothing now reports Blender's own error when there is one.

Verified on the real DH2 test.blend under Blender 5.2: the 1.6.2 parser
counts 0 of 3 saved frames, this one counts 3.

Bump worker to 1.6.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 04:42:33 +02:00

332 lines
13 KiB
C#

using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// 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.
/// </summary>
public sealed class BlenderRunner
{
private readonly WorkerOptions _options;
private readonly ApiClient _api;
private readonly ILogger<BlenderRunner> _log;
public BlenderRunner(WorkerOptions options, ApiClient api, ILogger<BlenderRunner> log)
{
_options = options;
_api = api;
_log = log;
}
public async Task<RenderResult> 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.
// -noaudio: a render node has no sound device, and initialising audio
// under a session-less service account is a known way to hang at startup.
var args = new List<string> { "-b", blendLocal, "-noaudio" };
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);
}
/// <summary>
/// Settings Blender has no command-line flag for. Returns null when the
/// submission asked for no overrides, so the file renders exactly as saved.
/// </summary>
private static string? BuildOverrideExpression(BlenderManifest m)
{
var parts = new List<string>();
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<RenderResult> ExecuteAsync(
string jobId,
string machineId,
List<string> 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);
}
// Any output at all means Blender is alive, which is what the stall
// watchdog is actually asking. Requiring a recognised progress line
// made the watchdog stricter than its own error message and killed
// healthy renders whose output we simply were not parsing.
lastProgressAt = DateTimeOffset.UtcNow;
var frame = BlenderProgressParser.ParseFrame(line);
if (frame is not null) currentFrame = frame.Value;
if (BlenderProgressParser.ParseSaved(line) is not null)
{
// Progress counts finished frames only: a frame emits hundreds
// of status updates while it renders.
framesSaved++;
eta.RecordFrame(DateTimeOffset.UtcNow);
}
if ((isStderr || BlenderProgressParser.IsErrorLine(line)) && !string.IsNullOrWhiteSpace(line))
{
if (BlenderProgressParser.IsErrorLine(line)) firstError ??= line.Trim();
if (BlenderProgressParser.IsNonRetryableError(line)) nonRetryable = 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);
}
// Deliberately not BeginOutputReadLine: that raises an event per
// newline, and Blender's render status is written with a carriage
// return so it overwrites one line in place. A frame that takes longer
// than the stall timeout would emit no newline for its whole duration
// and be killed as stalled while rendering perfectly well.
var pumps = new[]
{
PumpAsync(process.StandardOutput, line => HandleLine(line, isStderr: false)),
PumpAsync(process.StandardError, line => HandleLine(line, isStderr: true)),
};
try
{
return await MonitorAsync();
}
catch
{
// Disposing a Process does not end it: without this, any exception
// in the monitor loop leaves Blender rendering on the node,
// unsupervised, while the job is retried alongside it.
KillTree(process);
throw;
}
async Task<RenderResult> MonitorAsync()
{
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);
// Let the readers drain what is still buffered before judging the run —
// the last frame's "Saved:" often arrives after the process exits.
try { await Task.WhenAll(pumps).WaitAsync(TimeSpan.FromSeconds(10)); } catch { }
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,
firstError is not null
? $"Blender exited cleanly but saved no frames: {firstError}"
: "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);
}
}
/// <summary>
/// Reads a stream treating both CR and LF as line terminators, so
/// Blender's carriage-return progress updates are seen as they happen
/// rather than only when a newline eventually arrives.
/// </summary>
private static async Task PumpAsync(StreamReader reader, Action<string> onLine)
{
var buffer = new char[4096];
var current = new StringBuilder(256);
int read;
try
{
while ((read = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
for (var i = 0; i < read; i++)
{
var c = buffer[i];
if (c == '\r' || c == '\n')
{
if (current.Length > 0)
{
onLine(current.ToString());
current.Clear();
}
}
else
{
current.Append(c);
}
}
}
if (current.Length > 0) onLine(current.ToString());
}
catch (Exception)
{
// The process died mid-read; the exit path reports what happened.
}
}
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 { }
}
}