ae58dc0366
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>
185 lines
7.6 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|