Files
vfxreview/RenderWorker/VFXReviewWorker/ApiClient.cs
T
twotalesanimation cc89415a29 feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot
headlessly with the studio slate/overlay template to produce the delivery MOV
and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage.

Server:
- New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus
  Project.deliveryConfig and per-submission slate fields (Export.vfxScope,
  Export.submissionNote, inherited from the shot's previous export).
  Both migrations are purely additive; no existing column is touched.
- lib/render-pipeline: server-enforced state machine, transactional version
  increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by
  machine availability windows, and a lease reaper run from instrumentation.ts.
- /api/ext/* endpoints for the panel and workers; session-auth mirrors under
  /api/render and /api/machines for the web UI.
- Pipeline pages: render queue, export detail, machine monitoring, plus an
  Exports tab on shot detail.

RenderWorker (.NET 8 Windows service, new):
- Registration, heartbeat as cancel channel, claim loop, aerender runner with
  progress parsing and stall watchdog, crash recovery and disk-spooled
  reporting that survives server downtime.
- Preview stage: headless AE assembles the preview comp into a throwaway AEP
  with both output modules queued, then a single aerender pass renders them.
  Preview jobs are not claimed while an interactive AE session is open, so an
  artist's project is never taken over.

AE panel: Queue Export with live status polling, urgent flag, retry, and the
VFX Scope / Submission Note fields. Every existing panel action is unchanged.

Preview chaining ships disabled behind SystemConfig preview.enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:46:53 +02:00

185 lines
7.6 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace VFXReviewWorker;
/// <summary>
/// HTTP client for the VFXReview /api/ext pipeline endpoints. Workers talk
/// only HTTP — never a database (§2.1 principle 4).
/// </summary>
public sealed class ApiClient
{
private readonly HttpClient _http;
private readonly ILogger<ApiClient> _log;
public ApiClient(WorkerOptions options, ILogger<ApiClient> log)
{
_log = log;
_http = new HttpClient
{
BaseAddress = new Uri(options.ServerUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
}
private static readonly JsonSerializerOptions Json = WorkerOptions.JsonOpts;
// E6
public async Task<RegisterResponse> RegisterAsync(WorkerOptions o, CancellationToken ct)
{
var res = await _http.PostAsJsonAsync("api/ext/workers/register", new
{
name = o.MachineName,
hostname = Dns.GetHostName(),
workerVersion = o.WorkerVersion,
aeVersion = o.AeVersion,
capabilities = new { maxConcurrentJobs = 1, tools = new { ffmpeg = o.FfmpegPath != null, oiiotool = o.OiiotoolPath != null } },
}, Json, ct);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<RegisterResponse>(Json, ct))!;
}
// E7
public async Task<HeartbeatResponse> HeartbeatAsync(string machineId, string? currentJobId, CancellationToken ct)
{
var res = await _http.PostAsJsonAsync($"api/ext/workers/{machineId}/heartbeat", new
{
cpuPercent = (double?)null,
memPercent = (double?)null,
diskFreeGb = GetDiskFreeGb(),
currentJobId,
}, Json, ct);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<HeartbeatResponse>(Json, ct))!;
}
// E8 — null when the queue is empty / machine outside its render window (204)
public async Task<ClaimedJob?> ClaimAsync(string machineId, string[] types, CancellationToken ct)
{
var res = await _http.PostAsJsonAsync("api/ext/render/jobs/claim", new
{
machineId,
types,
}, Json, ct);
if (res.StatusCode == HttpStatusCode.NoContent) return null;
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ClaimResponse>(Json, ct);
return body?.Job;
}
// E9 — best-effort; failures are swallowed by the caller (lease/reaper covers us)
public async Task<ProgressResponse?> ProgressAsync(string jobId, string machineId, double progress,
int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress")
{
Content = JsonContent.Create(new { machineId, progress, currentFrame, totalFrames, etaSeconds, logTail }, options: Json),
};
var res = await _http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode) return null;
return await res.Content.ReadFromJsonAsync<ProgressResponse>(Json, ct);
}
// E10 / E11 — lifecycle reports, sent via the spooler for durability.
// Returns true when the server accepted (2xx) OR permanently rejected (409
// contradictory / 404 gone) — both mean "stop retrying".
public async Task<bool> SendLifecycleAsync(string kind, string jobId, Dictionary<string, object?> payload, CancellationToken ct)
{
var path = kind switch
{
"fail" => $"api/ext/render/jobs/{jobId}/fail",
"complete-render" => $"api/ext/render/jobs/{jobId}/complete-render",
"finalize" => $"api/ext/render/jobs/{jobId}/finalize",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"),
};
var res = await _http.PostAsJsonAsync(path, payload, Json, ct);
if (res.IsSuccessStatusCode) return true;
if (res.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.NotFound)
{
_log.LogWarning("Server permanently rejected {Kind} for job {JobId}: {Status} {Body}",
kind, jobId, (int)res.StatusCode, await res.Content.ReadAsStringAsync(ct));
return true; // don't retry contradictory/vanished reports
}
_log.LogWarning("Lifecycle report {Kind} for {JobId} failed with {Status}; will retry", kind, jobId, (int)res.StatusCode);
return false;
}
// E20 + PUT upload. Returns the storage key, or null when the upload failed.
public async Task<string?> UploadArtifactAsync(
string jobId, string machineId, string kind, string filePath, string contentType, CancellationToken ct)
{
try
{
if (!File.Exists(filePath))
{
_log.LogWarning("Artifact {Kind} not found at {Path}", kind, filePath);
return null;
}
var presignRes = await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-presign", new
{
machineId,
kind,
fileName = Path.GetFileName(filePath),
contentType,
}, Json, ct);
if (!presignRes.IsSuccessStatusCode)
{
_log.LogWarning("Presign for {Kind} failed: {Status}", kind, (int)presignRes.StatusCode);
return null;
}
var presign = await presignRes.Content.ReadFromJsonAsync<PresignResponse>(Json, ct);
if (presign is null) return null;
// Media files can be large — generous timeout, separate client so the
// presigned URL is not sent with our Authorization header.
using var upload = new HttpClient { Timeout = TimeSpan.FromMinutes(30) };
await using var stream = File.OpenRead(filePath);
var content = new StreamContent(stream);
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
var putRes = await upload.PutAsync(presign.PresignedUrl, content, ct);
if (!putRes.IsSuccessStatusCode)
{
_log.LogWarning("Upload of {Kind} failed: {Status}", kind, (int)putRes.StatusCode);
return null;
}
return presign.Key;
}
catch (Exception ex)
{
_log.LogWarning(ex, "Artifact upload ({Kind}) failed for job {JobId}", kind, jobId);
return null;
}
}
public Task<string?> UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) =>
UploadArtifactAsync(jobId, machineId, "log", logFilePath, "text/plain", ct);
// E3 — used by crash-recovery reconcile (§7.5)
public async Task<ExportDetail?> GetExportAsync(string exportId, CancellationToken ct)
{
var res = await _http.GetAsync($"api/ext/exports/{exportId}", ct);
if (!res.IsSuccessStatusCode) return null;
var body = await res.Content.ReadFromJsonAsync<ExportDetailResponse>(Json, ct);
return body?.Export;
}
private static double? GetDiskFreeGb()
{
try
{
var root = Path.GetPathRoot(Environment.SystemDirectory);
if (root is null) return null;
return Math.Round(new DriveInfo(root).AvailableFreeSpace / 1_073_741_824.0, 1);
}
catch
{
return null;
}
}
}