Files
twotalesanimation 7f066c1372
Deploy / deploy (push) Successful in 7m28s
feat(pipeline): MOV review delivery packages from selected shots
A formal review is shared as a flat, dated folder of the preview MOVs —
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review, then _02, _03
for further reviews the same day. It reuses the delivery package's
machinery end to end: DeliveryPackage/DeliveryItem, the DELIVERY_BUILD
job, the same claim path and the same hardlink-or-copy builder, with
DeliveryPackage.kind telling the two apart.

Three things separate a review from a delivery:

A review never advances an Export. Reviews happen throughout a shot's
life, long before QC passes it, and the same version is routinely shown
twice — so nothing is marked PACKAGED, and markDelivered() refuses a
review package outright (the button is hidden too).

Selection is by shot, not by export. Shot Status → Bulk Actions → Build
MOV Review Package; the server resolves each shot to its newest export
that actually has a deliveryMovPath, which is not always its newest
export — a shot whose latest version is still rendering belongs in the
review as the version that can be watched. Shots with no MOV at all are
listed in the confirm dialog with the reason rather than silently
shrinking the folder.

The folder is exactly the MOVs. No CSV, no manifest.json written beside
them; the manifest still returns to the server, so the record of what was
shared is kept without cluttering the share. Names follow the same
client-naming rule as a delivery, so a shot reads identically in a review
folder and in the delivery that follows it.

Verification (DeliveryStage.BuildReviewItem) is proportional as it is for
EXRs, adapted to a single file with no validation-time digest: a hard
link is the source and needs nothing, a copy is checked against its
source by length always and by full hash under "always-hash". A missing
MOV fails the item here — in an EXR delivery the MOV is a viewing copy
beside the real deliverable, but in a review it is the deliverable, and a
review folder quietly one shot short is worse than one that says so.

Worker bumped to 1.6.0 for the new manifest field (DeliveryManifest.Kind).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:58:21 +02:00

340 lines
13 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
namespace VFXReviewWorker;
// Wire types for the /api/ext pipeline endpoints (RenderPipeline2 §6).
public sealed class RegisterResponse
{
public MachineInfo Machine { get; set; } = new();
public ServerConfig Config { get; set; } = new();
}
public sealed class MachineInfo
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public bool Enabled { get; set; }
}
/// <summary>Server-supplied tuning (E6) — fleet-tunable without touching installs.</summary>
public sealed class ServerConfig
{
public int PollSeconds { get; set; } = 10;
public int HeartbeatSeconds { get; set; } = 30;
public int LeaseSeconds { get; set; } = 300;
public int MaxAttempts { get; set; } = 3;
public int StallTimeoutSeconds { get; set; } = 600;
/// <summary>
/// Validation stage (§8). The server decides whether it runs, so turning it
/// off is a config change there rather than a redeploy of every worker.
/// </summary>
public bool ValidationEnabled { get; set; } = true;
public int ValidationSampleEvery { get; set; } = 25;
/// <summary>Head handles the plates carry; only used for the API timecode fallback (§8.1a).</summary>
public int ValidationHandleFrames { get; set; } = 8;
}
public sealed class ClaimResponse
{
public ClaimedJob Job { get; set; } = new();
}
public sealed class ClaimedJob
{
public string Id { get; set; } = "";
public string Type { get; set; } = "AE_RENDER";
public int Attempt { get; set; }
public int MaxAttempts { get; set; }
public string? ExportId { get; set; }
public string? DeliveryId { get; set; }
/// <summary>Set for jobs with no shot behind them; shown instead of a shot code.</summary>
public string? Label { get; set; }
public DateTimeOffset? LeaseExpiresAt { get; set; }
/// <summary>Manifest shape depends on the job type — deserialize per stage.</summary>
public JsonElement Manifest { get; set; }
public RenderManifest AsRenderManifest() =>
Manifest.Deserialize<RenderManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a render manifest");
public PreviewManifest AsPreviewManifest() =>
Manifest.Deserialize<PreviewManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a preview manifest");
public DeliveryManifest AsDeliveryManifest() =>
Manifest.Deserialize<DeliveryManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a delivery manifest");
public BlenderManifest AsBlenderManifest() =>
Manifest.Deserialize<BlenderManifest>(WorkerOptions.JsonOpts)
?? throw new InvalidOperationException("Job manifest is not a Blender manifest");
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>DELIVERY_BUILD manifest (server-built, RenderPipeline2 §11).</summary>
public sealed class DeliveryManifest
{
public string Stage { get; set; } = "DELIVERY";
/// <summary>
/// "EXR_DELIVERY" builds the per-shot folders of EXRs + MOVs; "MOV_REVIEW"
/// builds a flat folder of preview MOVs for a formal review (§11.6). One
/// job type and one claim path either way — only what lands on disk differs.
/// </summary>
public string Kind { get; set; } = "EXR_DELIVERY";
public string PackageId { get; set; } = "";
public string PackageName { get; set; } = "";
public int PackageNumber { get; set; }
public string Episode { get; set; } = "";
public string PackageDir { get; set; } = "";
public string ReportFile { get; set; } = "";
public bool IncludeMovs { get; set; } = true;
public string CopyMode { get; set; } = "auto";
/// <summary>"auto" verifies copies by hash and links by identity; "always-hash" reads everything back.</summary>
public string VerifyMode { get; set; } = "auto";
public string ChecksumAlgo { get; set; } = "xxh64";
public List<string> ReportFormats { get; set; } = new();
public List<DeliveryManifestItem> Items { get; set; } = new();
}
public sealed class DeliveryManifestItem
{
public string ItemId { get; set; } = "";
public string ExportId { get; set; } = "";
public string ShotCode { get; set; } = "";
public string VersionString { get; set; } = "";
public string SourceDir { get; set; } = "";
public string OutputPattern { get; set; } = "";
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
public int? FileCount { get; set; }
public string? Checksum { get; set; }
public string? DeliveryMovPath { get; set; }
public string ShotDir { get; set; } = "";
/// <summary>
/// Server-computed: "{clientCode}_{versionString}" when the shot has a
/// client naming code, else the studio's own "{shotCode}_cmp_TT_{versionString}".
/// The delivered EXR sequence and MOV are named from this, not the
/// internal render filename they're copied from.
/// </summary>
public string DeliveredBaseName { get; set; } = "";
/// <summary>When this export's validation ran — lets a linked package detect a source changed since QC.</summary>
public DateTimeOffset? ValidatedAt { get; set; }
/// <summary>
/// True on a MOV_REVIEW item: there is no EXR sequence, so SourceDir,
/// OutputPattern, FileCount and Checksum are empty and DeliveryMovPath is
/// the whole deliverable.
/// </summary>
public bool IsMovOnly => string.IsNullOrWhiteSpace(SourceDir);
}
/// <summary>PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9).</summary>
public sealed class PreviewManifest
{
public string Stage { get; set; } = "PREVIEW";
public string ExportId { get; set; } = "";
public string ShotCode { get; set; } = "";
public string VersionString { get; set; } = "";
public string OutputDir { get; set; } = "";
public string OutputPattern { get; set; } = "";
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
public double Fps { get; set; } = 24;
public string TemplateAep { get; set; } = "";
public string TemplateComp { get; set; } = "";
public string? OverlayComp { get; set; }
public string? LutComp { get; set; }
public string MovTemplate { get; set; } = "";
public string Mp4Template { get; set; } = "";
public string MovOutput { get; set; } = "";
public string Mp4Output { get; set; } = "";
public string? SlateScopeProp { get; set; }
public string? SlateSubmissionProp { get; set; }
public SlateInfo Slate { get; set; } = new();
public int TotalFrames => FrameEnd - FrameStart + 1;
}
public sealed class SlateInfo
{
public string VersionName { get; set; } = "";
public string Date { get; set; } = "";
public string? Description { get; set; }
public string? Notes { get; set; }
public string ShotCode { get; set; } = "";
/// <summary>Production name, burned into the slate and the overlay.</summary>
public string? Show { get; set; }
public string? Episode { get; set; }
public string? Scene { get; set; }
public string? VfxScope { get; set; }
public string? SubmissionNote { get; set; }
}
/// <summary>Result JSON written by vfxr_build_preview.jsx.</summary>
public sealed class PreviewBuildResult
{
public bool Ok { get; set; }
public string? Error { get; set; }
public List<string> Warnings { get; set; } = new();
public string? TempAep { get; set; }
public string? PreviewComp { get; set; }
public int? FrameCount { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
/// <summary>
/// Seconds of slate ahead of picture in the preview comp (the SHOT layer's
/// in-point). Null on builds predating this field, or when the template has
/// no SHOT layer — the thumbnail then falls back to scanning from frame 0.
/// </summary>
public double? PictureStartSeconds { get; set; }
}
/// <summary>The Render Manifest (§6.2) — the fields the render and validation stages need; the rest is preserved raw.</summary>
public sealed class RenderManifest
{
public string ShotCode { get; set; } = "";
public string AepPath { get; set; } = "";
public string CompName { get; set; } = "";
public string OutputDir { get; set; } = "";
public string OutputPattern { get; set; } = "";
public string? OutputModuleTemplate { get; set; }
public string? RenderSettingsTemplate { get; set; }
/// <summary>Comp timeline range handed to aerender (-s/-e).</summary>
public int FrameStart { get; set; }
public int FrameEnd { get; set; }
/// <summary>
/// The number the *first output file* carries — the output module's
/// starting number (house convention 1001), which is deliberately not the
/// comp's frame range: a comp anchored to the plate's timecode starts in
/// the millions. Null on manifests predating this field.
/// </summary>
public int? OutputFrameStart { get; set; }
public double Fps { get; set; } = 24;
public int Width { get; set; }
public int Height { get; set; }
public ManifestExpected? Expected { get; set; }
public ManifestSource? Source { get; set; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extra { get; set; }
public int TotalFrames => FrameEnd - FrameStart + 1;
}
/// <summary>Per-project expectations the validation engine checks against (§8.1).</summary>
public sealed class ManifestExpected
{
public string? Colorspace { get; set; }
public string? BitDepth { get; set; }
public string? ExrCompression { get; set; }
public bool? Alpha { get; set; }
/// <summary>The API's editorial in-point — the rank 3 timecode fallback only (§8.1a).</summary>
public string? TimecodeStart { get; set; }
}
/// <summary>Plate provenance resolved by the panel from the comp's footage layers (§6.2).</summary>
public sealed class ManifestSource
{
public PlateRef? TimingPlate { get; set; }
public List<PlateRef> Plates { get; set; } = new();
public string? StartSource { get; set; }
public int? StartFrames { get; set; }
public int? HandleFrames { get; set; }
}
public sealed class PlateRef
{
public string? Element { get; set; }
public string Path { get; set; } = "";
public int? FrameStart { get; set; }
public int? FrameEnd { get; set; }
}
public sealed class ProgressResponse
{
public bool Ok { get; set; }
public bool CancelRequested { get; set; }
public DateTimeOffset? LeaseExpiresAt { get; set; }
}
public sealed class HeartbeatResponse
{
public bool Ok { get; set; }
public List<WorkerCommand> Commands { get; set; } = new();
}
public sealed class WorkerCommand
{
public string Type { get; set; } = "";
public string? JobId { get; set; }
}
public sealed class PresignResponse
{
public string PresignedUrl { get; set; } = "";
public string Key { get; set; } = "";
public string Url { get; set; } = "";
}
public sealed class ExportDetailResponse
{
public ExportDetail Export { get; set; } = new();
}
public sealed class ExportDetail
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";
public List<RenderJobDetail> RenderJobs { get; set; } = new();
}
public sealed class RenderJobDetail
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";
}
/// <summary>Durable lifecycle report persisted to the spool directory (§7.6).</summary>
public sealed class SpooledReport
{
public string Kind { get; set; } = ""; // "fail" | "complete-render"
public string JobId { get; set; } = "";
public Dictionary<string, object?> Payload { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
/// <summary>current-job.json — crash recovery state (§7.5).</summary>
public sealed class CurrentJobState
{
public string JobId { get; set; } = "";
public string? ExportId { get; set; } = "";
public string MachineId { get; set; } = "";
public string OutputDirLocal { get; set; } = "";
public DateTimeOffset ClaimedAt { get; set; }
}