Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59af7959e6 | |||
| a6ee1917e7 | |||
| 28e8c47907 | |||
| 94792279c5 | |||
| 93137cdcd2 | |||
| 6678721fb1 | |||
| bac525ddd0 | |||
| 7f066c1372 | |||
| ed9f9b13c1 | |||
| 1fad6a739d | |||
| 9cd7b0df6e | |||
| caaa333f02 | |||
| b151e8cc52 | |||
| 26a4719c2a | |||
| a18dfc6203 | |||
| 67d8639861 | |||
| 86962eb4bd | |||
| 82dfaee56f | |||
| df219d7932 | |||
| cf55a53c06 | |||
| 53fc8dc65b | |||
| ed8a225106 | |||
| a18aad4373 | |||
| 3733c0c4fa | |||
| 70cf45ead4 | |||
| bada122f42 | |||
| 483db65eb3 | |||
| 41ee826700 | |||
| c9b0f25bb2 | |||
| 4642ecb11f | |||
| e66e4868ea | |||
| af810258e6 | |||
| 991abe072c | |||
| 1163bd6455 | |||
| 37f73a2bd7 | |||
| 4929db45a2 | |||
| c4a85bac2b | |||
| f4a2fee492 | |||
| ce7b73dd38 | |||
| ca9a004d56 | |||
| a4f8226f3f | |||
| feb1c7b3e9 | |||
| 7f2f5e1005 | |||
| 0ce41123c5 | |||
| 783e82eaf5 | |||
| c4abda2d63 | |||
| 7dcb9303e5 | |||
| 7caf41e17c | |||
| 2f2e286adb | |||
| c9deb437c5 | |||
| a3be3489de | |||
| cc89415a29 | |||
| 6b15bae62a | |||
| 7b36329769 | |||
| d2c91e7b65 | |||
| 852081b4d6 | |||
| 98120f3ca8 | |||
| 5f3c89119a | |||
| c727795a78 | |||
| 43c717bb85 | |||
| ae58dc0366 |
@@ -31,3 +31,6 @@ yarn-error.log*
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# RenderWorker build output is covered by RenderWorker/.gitignore (bin/, obj/,
|
||||
# publish/) — the worker's source is tracked like the rest of the repo.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
|
||||
**The add-on sends its own User-Agent** (`VFXReviewBlender/<version>`). Python's
|
||||
`urllib` otherwise identifies itself as `Python-urllib/3.x`, which Cloudflare
|
||||
blocks outright as a scripted signature — a 403 with error 1010 before the
|
||||
request ever reaches the server. Verified against the live zone: `Python-urllib`
|
||||
gets 403, the add-on's own agent gets 200. If a future Cloudflare rule blocks it
|
||||
anyway, the add-on says so in plain language rather than showing the raw JSON,
|
||||
and the fix is a WAF allow rule on the agent or on `/api/ext/*`.
|
||||
@@ -0,0 +1,289 @@
|
||||
bl_info = {
|
||||
"name": "VFXReview — Submit Render",
|
||||
"author": "Two Tales",
|
||||
"version": (1, 0, 2),
|
||||
"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
|
||||
|
||||
|
||||
ADDON_VERSION = ".".join(str(n) for n in bl_info["version"])
|
||||
USER_AGENT = "VFXReviewBlender/%s (Blender %s)" % (ADDON_VERSION, bpy.app.version_string)
|
||||
|
||||
|
||||
# ── 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")
|
||||
# urllib identifies itself as "Python-urllib/3.x" by default, which
|
||||
# Cloudflare blocks outright as a scripted signature (error 1010). Sending a
|
||||
# real name for this tool is also the honest thing: the studio's logs should
|
||||
# say what is calling them.
|
||||
req.add_header("User-Agent", USER_AGENT)
|
||||
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:
|
||||
parsed = json.loads(body)
|
||||
except Exception:
|
||||
parsed = {}
|
||||
|
||||
# A Cloudflare block is not a pipeline error and reads nothing like one,
|
||||
# so say what it actually is rather than showing the artist a wall of
|
||||
# JSON about browser signatures.
|
||||
if parsed.get("cloudflare_error") or parsed.get("error_code") == 1010:
|
||||
raise RuntimeError(
|
||||
"Blocked by Cloudflare before reaching the server (error %s). "
|
||||
"The studio's firewall needs to allow this add-on — ask the pipeline TD."
|
||||
% parsed.get("error_code", e.code)
|
||||
)
|
||||
|
||||
message = parsed.get("error", body) if parsed else 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 = []
|
||||
|
||||
# Blender does not copy the strings an EnumProperty callback returns — Python
|
||||
# must keep a reference to them or the UI renders freed memory, which shows up
|
||||
# as garbled labels rather than as a crash. So the items are built once when the
|
||||
# project list changes and held here; the callback only ever returns this list.
|
||||
_enum_items = [("", "Refresh projects...", "")]
|
||||
|
||||
|
||||
def rebuild_enum_items():
|
||||
global _enum_items
|
||||
if _project_cache:
|
||||
_enum_items = [
|
||||
(p["code"], "%s - %s" % (p["code"], p["name"]), "")
|
||||
for p in _project_cache
|
||||
]
|
||||
else:
|
||||
_enum_items = [("", "Refresh projects...", "")]
|
||||
return _enum_items
|
||||
|
||||
|
||||
def project_items(self, context):
|
||||
"""Called on every redraw, so it does no work and allocates nothing."""
|
||||
return _enum_items
|
||||
|
||||
|
||||
# ── 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", [])
|
||||
rebuild_enum_items()
|
||||
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()
|
||||
@@ -0,0 +1,310 @@
|
||||
# Delivery Packages — Workflow Guide
|
||||
|
||||
How a finished shot becomes a dated folder ready to upload to the client.
|
||||
|
||||
There are two kinds of package. An **EXR delivery** is the shippable one and is
|
||||
what most of this guide describes. A **MOV review** is a flat folder of preview
|
||||
MOVs shared for a formal review — see
|
||||
[MOV review packages](#mov-review-packages) at the end.
|
||||
|
||||
This is the operational guide. The design rationale and normative rules live in
|
||||
[RenderPipeline2.md](RenderPipeline2.md) §11.
|
||||
|
||||
---
|
||||
|
||||
## The short version
|
||||
|
||||
1. A shot renders, validates, gets a preview, and **passes QC** → it becomes
|
||||
eligible for delivery.
|
||||
2. On **Pipeline → Deliveries**, pick the project, see *"Episode 101 — Ready:
|
||||
18 shots"*, click **Build delivery**.
|
||||
3. A render worker builds the folder on the delivery volume, verifies every
|
||||
file, and writes a manifest and CSV report.
|
||||
4. A human uploads that folder to the client, then clicks **Mark delivered**.
|
||||
|
||||
Nothing is ever sent to the client automatically. Step 4 is deliberately manual.
|
||||
|
||||
---
|
||||
|
||||
## What makes a shot eligible
|
||||
|
||||
An Export reaches `READY_FOR_DELIVERY` only by passing QC — from the AE panel
|
||||
or the web. That is the single gate; there is no way to package an export that
|
||||
has not been QC'd.
|
||||
|
||||
A render that failed automated validation can still get there: **Override
|
||||
validation** on the Export detail page (admin/producer/supervisor, reason
|
||||
required) resumes the pipeline at the preview stage, so the shot still goes
|
||||
through QC like any other. The failed checks stay on record and keep appearing
|
||||
as warnings in the QC queue, so whoever QCs it knows what was waved through.
|
||||
|
||||
Two rules worth knowing:
|
||||
|
||||
- **Newest version per shot only.** If a shot has two exports sitting ready, the
|
||||
older one is history and is not offered. Building refuses outright if you
|
||||
somehow select two versions of the same shot.
|
||||
- **Version locking.** A package references the exact Export rows it was built
|
||||
from and copies the shot code and version string at creation time. Re-render a
|
||||
shot afterwards and the package is untouched — delivery history is immutable
|
||||
by construction.
|
||||
|
||||
---
|
||||
|
||||
## Building a package
|
||||
|
||||
**Pipeline → Deliveries**, choose a project. Each episode with ready shots shows
|
||||
its count and the shots themselves, with frame counts and sizes.
|
||||
|
||||
**Build delivery** creates the package (`QUEUED`) and a `DELIVERY_BUILD` job on
|
||||
the same queue the renders use. Requires **admin, producer or supervisor**.
|
||||
|
||||
Delivery jobs run at priority 10, which is inside the urgent threshold — so they
|
||||
claim immediately even during work hours, when a render would wait for the
|
||||
evening window. Packaging is file work with no After Effects involvement, so it
|
||||
does not disturb an artist at the machine.
|
||||
|
||||
Watch the package status on the same page:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `QUEUED` | Waiting for a worker to pick it up |
|
||||
| `BUILDING` | A worker is creating folders and linking files |
|
||||
| `READY` | Built and verified — upload it |
|
||||
| `DELIVERED` | You confirmed the client has it |
|
||||
| `FAILED` | Something did not verify — see the error and the per-shot list |
|
||||
| `CANCELLED` | Abandoned before delivery |
|
||||
|
||||
---
|
||||
|
||||
## What lands on disk
|
||||
|
||||
Default layout (the studio's confirmed structure):
|
||||
|
||||
```
|
||||
{deliveryRoot}/{episode}/{YYMMDD}_Delivery/
|
||||
├── {SHOT_CODE}/
|
||||
│ ├── {shot}_cmp_TT_{ver}.[#####].exr ← the EXR sequence
|
||||
│ └── {shot}_cmp_TT_{ver}.mov ← slate/burn-in delivery MOV
|
||||
├── manifest.json ← every file, size, checksum, source export
|
||||
└── {YYMMDD}_Delivery_report.csv ← one row per shot (client-facing)
|
||||
```
|
||||
|
||||
EXRs and the MOV sit side by side in each shot folder — no `exr/` and `mov/`
|
||||
subfolders, matching current practice.
|
||||
|
||||
The CSV carries: shot, version, frame range, frame count, file count, bytes,
|
||||
checksum, MOV filename and status. Confirm these columns against the
|
||||
production's delivery spec before the first real delivery.
|
||||
|
||||
---
|
||||
|
||||
## Hard links, and why the package appears instantly
|
||||
|
||||
Files are **hard-linked**, not copied, when the source and destination are on the
|
||||
same volume. A hard link is a second name for the same data — not a shortcut and
|
||||
not a copy — so an 18-shot EXR package builds in seconds and consumes
|
||||
essentially no extra disk.
|
||||
|
||||
Verified on the studio SAN (`\\TRUENAS\vfx` over SMB): hard links work there, and
|
||||
renders and deliveries live on the same share.
|
||||
|
||||
**Is that safe?** Deleting the delivery folder does not delete the render — the
|
||||
data survives until the last name referring to it is gone. And nothing in the
|
||||
pipeline ever rewrites an existing EXR: a new version is always a new directory.
|
||||
|
||||
Two escape hatches:
|
||||
|
||||
- A delivery root on a **different volume** falls back to copying automatically.
|
||||
- `"copyMode": "always-copy"` in the project's delivery config forces real copies
|
||||
for a production that mandates physical separation.
|
||||
|
||||
The `linkMode` column on each item records what actually happened, so a package
|
||||
never claims to have linked when it copied.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Verification is proportional to what the builder actually did, which is what
|
||||
keeps a package fast:
|
||||
|
||||
| Mode | How it verifies | Cost for a 25-shot episode |
|
||||
|---|---|---|
|
||||
| **Hard-linked** (default) | The entry *is* the source data, so the validation-time digest already describes it. Checks each file exists, and that no source was modified after it passed QC. | seconds |
|
||||
| **Copied** | Read back in full and hashed; the sequence digest must match the one recorded at validation. | ~30 s per 2.8 GB shot |
|
||||
|
||||
Re-reading a hard link cannot disagree with validation — it is the same bytes
|
||||
under a second name — and at ~100 MB/s over SMB it costs roughly half an hour
|
||||
per episode to prove something that cannot be false. A copy is genuinely
|
||||
different: it can be truncated or corrupted, so it is always read back.
|
||||
|
||||
Set `"verifyMode": "always-hash"` in the delivery config to read everything back
|
||||
regardless, for a production that requires it contractually.
|
||||
|
||||
An item fails, and the whole package fails with it, when:
|
||||
|
||||
| Failure | Meaning |
|
||||
|---|---|
|
||||
| Checksum mismatch (copied items) | The delivered bytes differ from what passed QC |
|
||||
| A source file modified after validation | The render changed after it passed QC — a link would carry the new bytes faithfully |
|
||||
| Frame count differs from the export record | The render folder changed after QC — someone re-rendered in place |
|
||||
| Source folder missing | The render is not where the export says it is |
|
||||
| No matching files | The folder exists but holds nothing matching the output pattern |
|
||||
|
||||
**A package only reaches `READY` when every shot verified.** One bad shot means
|
||||
`FAILED`, and no export is marked as packaged — there is no partial delivery.
|
||||
|
||||
A missing delivery MOV is the one non-fatal case: the EXRs are the deliverable,
|
||||
so it is logged and the package continues without it.
|
||||
|
||||
> **Without validation there is nothing to verify against.** An export whose
|
||||
> checksum was never recorded (validation disabled, or rendered before it
|
||||
> shipped) still packages, but the digest is computed with nothing to compare it
|
||||
> to. The ready list flags these as *"N without a validation checksum"*.
|
||||
|
||||
---
|
||||
|
||||
## Marking delivered
|
||||
|
||||
Once you have uploaded the folder, **Mark delivered** moves every packaged
|
||||
export to `DELIVERED`. Requires admin, producer or supervisor.
|
||||
|
||||
This is the end of the pipeline, and it is a human statement of fact — the app
|
||||
has no way to know the client received anything.
|
||||
|
||||
---
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
**A package failed.** Expand it: every shot shows its own status and error. Fix
|
||||
the cause (usually a re-render after QC), then build a new package. Packages are
|
||||
never deleted; a mistaken one is cancelled.
|
||||
|
||||
**The worker died mid-build.** The package is marked `FAILED` with *"check the
|
||||
package folder before rebuilding"*. It is deliberately not retried
|
||||
automatically — a half-written folder on the delivery volume is something a
|
||||
human should look at first.
|
||||
|
||||
**Cancelling.** Only `DRAFT`, `QUEUED` and `FAILED` packages can be cancelled. A
|
||||
`READY` or `DELIVERED` package stays in the history permanently; supersede it by
|
||||
building a new one.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Per project, in `Project.deliveryConfig`; studio-wide defaults in the
|
||||
`delivery.defaultConfig` SystemConfig key; built-in defaults otherwise. Merged
|
||||
key by key, so a project can override just `deliveryRoot`.
|
||||
|
||||
```json
|
||||
{
|
||||
"packageNameTemplate": "{yymmdd}_Delivery",
|
||||
"layout": {
|
||||
"packageDir": "{deliveryRoot}/{episode}/{packageName}",
|
||||
"shotDir": "{packageDir}/{shotCode}",
|
||||
"reportFile": "{packageDir}/{packageName}_report.csv"
|
||||
},
|
||||
"deliveryRoot": "//SAN/DELIVERY",
|
||||
"includeMovs": true,
|
||||
"checksumAlgo": "xxh64",
|
||||
"reportFormats": ["csv", "json"],
|
||||
"copyMode": "auto",
|
||||
"verifyMode": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
Tokens: `{showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir}
|
||||
{deliveryRoot} {shotCode} {versionString} {packageNumber}`.
|
||||
|
||||
The resolved config is **snapshotted onto the package** at build time, so a
|
||||
historic package always shows the rules it was built with, and a production with
|
||||
a different spec is a config change rather than a code change.
|
||||
|
||||
`deliveryRoot` must be reachable *from the worker machine* — check with
|
||||
`VFXReviewWorker.exe --check` if a build fails on paths.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| # | Method | Path | Purpose |
|
||||
|---|--------|------|---------|
|
||||
| E16 | `POST` | `/api/deliveries` | Create a package and queue the build |
|
||||
| E17 | `GET` | `/api/deliveries` | List packages / history |
|
||||
| E18 | `GET` | `/api/deliveries/ready` | Ready exports grouped by episode |
|
||||
| E19 | `POST` | `/api/deliveries/{id}/delivered` | Mark delivered |
|
||||
| — | `POST` | `/api/deliveries/{id}/cancel` | Cancel a package |
|
||||
| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | Worker reports a build (API key) |
|
||||
|
||||
`POST /api/deliveries` takes `{ projectId, episode?, exportIds?, options? }`.
|
||||
Omit `exportIds` to take every ready export in the episode; `options` accepts
|
||||
`date`, `name`, `includeMovs` and `copyMode` to override the config for one
|
||||
build.
|
||||
|
||||
---
|
||||
|
||||
## MOV review packages
|
||||
|
||||
For a formal review you share the preview MOVs, not the EXRs. That is its own
|
||||
kind of package, built the same way and listed on the same page.
|
||||
|
||||
### Building one
|
||||
|
||||
**Shot Status**, tick the shots, then **Bulk Actions → Build MOV Review
|
||||
Package**. A dialog shows exactly what would be built before anything happens:
|
||||
the folder name, the version each shot contributes, and any shots being left
|
||||
out. Requires **admin, producer or supervisor**.
|
||||
|
||||
The version each shot contributes is its **newest export that actually has a
|
||||
preview MOV** — not necessarily its newest export. A shot whose latest version
|
||||
is still rendering goes into the review as the version that can be watched.
|
||||
A shot with no MOV at all is listed as left out, with the reason, rather than
|
||||
quietly reducing the folder.
|
||||
|
||||
### What lands on disk
|
||||
|
||||
```
|
||||
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review/
|
||||
├── EP6_VFX_012_v004.mov
|
||||
├── EP6_VFX_013_v002.mov
|
||||
└── UNG_106_010_040_cmp_TT_v001.mov
|
||||
```
|
||||
|
||||
Flat — the MOVs sit beside each other so they can be scrubbed through in order.
|
||||
Names follow the same client-naming rule as a delivery: the production's own
|
||||
code where it has one, ours where it does not, so a shot reads identically in a
|
||||
review folder and in the delivery that follows it.
|
||||
|
||||
The folder is **exactly the MOVs**: no CSV, no `manifest.json` beside them. The
|
||||
manifest still goes back to the app, so the record of what was shared is kept
|
||||
without cluttering the share.
|
||||
|
||||
Second review the same day is `20260828_Producer_review_02`, then `_03`. A
|
||||
number is never reused, even if the package that took it was cancelled.
|
||||
|
||||
### What it does not do
|
||||
|
||||
**A review never moves a shot along.** Reviews happen throughout a shot's life,
|
||||
long before QC passes it, and the same version is routinely shown twice — so
|
||||
building one changes nothing about the exports in it, and there is no
|
||||
**Mark delivered** on a review package. Shots stay exactly where they are in
|
||||
the pipeline.
|
||||
|
||||
A **missing MOV fails the package** here, unlike in an EXR delivery where a
|
||||
missing MOV is logged and the build continues. In a review the MOV is the
|
||||
deliverable, and a review folder quietly one shot short is worse than one that
|
||||
says so.
|
||||
|
||||
### Configuration
|
||||
|
||||
Two more keys in the same `deliveryConfig` (§11.6):
|
||||
|
||||
```json
|
||||
{
|
||||
"reviewNameTemplate": "{yyyymmdd}_Producer_review",
|
||||
"layout": {
|
||||
"reviewDir": "{deliveryRoot}/PRODUCER_REVIEWS/{packageName}"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -24,6 +24,30 @@ X-Api-Key: <API_SECRET_KEY>
|
||||
| `POST` | `/api/ext/shots` | Create a new shot |
|
||||
| `PATCH` | `/api/ext/shots/{shotId}` | Update shot fields (e.g. increment version) |
|
||||
|
||||
### Render pipeline (RenderPipeline2)
|
||||
|
||||
| # | Method | Path | Caller | Description |
|
||||
|---|--------|------|--------|-------------|
|
||||
| E1 | `POST` | `/api/ext/exports` | panel | Queue Export — creates Export + RenderJob |
|
||||
| E2 | `GET` | `/api/ext/exports/latest` | panel | Latest export + live status for the panel header |
|
||||
| E3 | `GET` | `/api/ext/exports/{exportId}` | panel/worker | Export detail incl. validations and QC |
|
||||
| E4 | `POST` | `/api/ext/exports/{exportId}/qc` | panel | QC Pass / QC Fail |
|
||||
| E5 | `GET` | `/api/ext/qc/queue` | panel | Exports in READY_FOR_QC |
|
||||
| E6 | `POST` | `/api/ext/workers/register` | worker | Register machine, receive tuning |
|
||||
| E7 | `POST` | `/api/ext/workers/{machineId}/heartbeat` | worker | Heartbeat + cancel channel |
|
||||
| E8 | `POST` | `/api/ext/render/jobs/claim` | worker | Atomically claim the next job |
|
||||
| E9 | `PATCH` | `/api/ext/render/jobs/{jobId}/progress` | worker | Progress/ETA; renews the lease |
|
||||
| E10 | `POST` | `/api/ext/render/jobs/{jobId}/fail` | worker | Report failure |
|
||||
| E11 | `POST` | `/api/ext/render/jobs/{jobId}/complete-render` | worker | aerender finished → VALIDATING |
|
||||
| E12 | `POST` | `/api/ext/render/jobs/{jobId}/validation` | worker | Report all validation results |
|
||||
| E13 | `POST` | `/api/ext/render/jobs/{jobId}/finalize` | worker | Preview artifacts done → READY_FOR_QC |
|
||||
| E14 | `POST` | `/api/ext/exports/{exportId}/retry` | panel | Retry a failed export |
|
||||
| E20 | `POST` | `/api/ext/render/jobs/{jobId}/artifact-presign` | worker | Presigned upload URL |
|
||||
| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | worker | Report a DELIVERY_BUILD result |
|
||||
|
||||
Session-authenticated twins for the web UI live under `/api/render/*` and
|
||||
`/api/deliveries/*` — same service layer, no API key.
|
||||
|
||||
---
|
||||
|
||||
## Shot Status Values
|
||||
@@ -513,3 +537,9 @@ For reference, the endpoints called by `VFXReviewConnector.jsx`:
|
||||
| Populate Shot dropdown | `GET /api/ext/projects/{PROJECT_CODE}/shots?episode={value}` |
|
||||
| All shot operations (overlay, queue, preview, delivery) | `GET /api/ext/shots/lookup?shotCode={code}&projectCode={PROJECT_CODE}` |
|
||||
| Increment Shot Version | `PATCH /api/ext/shots/{shot.id}` with `{ "shotVersion": "v00X" }` |
|
||||
| Queue Export | `POST /api/ext/exports` (E1) |
|
||||
| Refresh Status / poll | `GET /api/ext/exports/latest` (E2) |
|
||||
| Retry Export | `POST /api/ext/exports/{id}/retry` (E14) |
|
||||
| Regenerate Previews | `POST /api/ext/exports/{id}/regenerate-preview` |
|
||||
| Load QC Queue | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5) |
|
||||
| QC Pass / QC Fail | `POST /api/ext/exports/{id}/qc` (E4) |
|
||||
|
||||
@@ -166,6 +166,20 @@ prisma/ — schema.prisma, seed.ts
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Covers |
|
||||
|---|---|
|
||||
| [RenderPipeline2.md](RenderPipeline2.md) | Render pipeline specification — normative design, state machine, API contracts |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
+1524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
bin/
|
||||
obj/
|
||||
publish/
|
||||
*.user
|
||||
|
||||
# Published packages — rebuildable from any commit with scripts/publish.ps1,
|
||||
# and the version stamp (e.g. 1.1.0+a4f8226) already traces a deployed exe
|
||||
# back to its source. Keeping ~29 MB per publish in history buys nothing.
|
||||
VFXReviewWorker_*.zip
|
||||
@@ -0,0 +1,252 @@
|
||||
# VFXReview RenderWorker
|
||||
|
||||
Windows service that drives `aerender.exe` on the artist workstations (RenderPipeline2 §7).
|
||||
It talks **only HTTP** to the VFXReview server (`/api/ext/*`, API-key auth) — it never
|
||||
gets a database connection. All scheduling policy (render windows, Render Now,
|
||||
urgent priority) is enforced server-side in the claim endpoint; the worker polls dumbly.
|
||||
|
||||
## One click → EXR + MOV + MP4
|
||||
|
||||
A single **Queue Export** click produces all three deliverables. The server
|
||||
chains two jobs:
|
||||
|
||||
1. **AE_RENDER** — `aerender` writes the clean EXR sequence (no overlay, no LUT).
|
||||
2. **PREVIEW_ONLY** — created automatically when the render completes. The
|
||||
worker runs `AfterFX.com -noui -r scripts/vfxr_build_preview.jsx`, which opens
|
||||
the studio slate/overlay **template** AEP, imports the rendered EXRs, rebuilds
|
||||
the shot around them (OCIO → `_SHOW LUT` → `UNG_VFX_OVERLAY`), duplicates
|
||||
`UNG_EXPORT_TEMPLATE` into a preview comp with the slate filled in, queues the
|
||||
MOV (`4444 Tri`) and MP4 (`REVIEW_PREVIEW`) output modules, and **saves a
|
||||
throwaway AEP**. The worker then runs `aerender -project <that aep>` with no
|
||||
`-comp`, rendering both outputs in one launch.
|
||||
|
||||
The MOV and MP4 land beside the EXRs. The MP4 is uploaded and registered as an
|
||||
ordinary `Version` — internal-only, never client-visible, and it changes no task
|
||||
or shot status (§10.0). Preview settings live in `SystemConfig` under
|
||||
`preview.*`, so template names and paths are changed without redeploying.
|
||||
|
||||
If the preview stage fails, only it is retried — the validated EXRs are never
|
||||
re-rendered. The temp AEP is kept on failure so you can open it and see exactly
|
||||
what the farm built.
|
||||
|
||||
### Proving headless AE first
|
||||
|
||||
Headless AE is the one real unknown, so prove it before relying on it
|
||||
(spec 18.2-C4). **Close After Effects**, then:
|
||||
|
||||
```powershell
|
||||
.\scripts\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\...\v002" -ShotCode "UNG_111_001_030" -Version "v002"
|
||||
```
|
||||
|
||||
It runs the exact build the worker runs and prints the result plus any warnings
|
||||
(missing LUT comp, missing slate layer, …), then gives you the `aerender` command
|
||||
to render what it built. If it reports no result file, AE cannot script headlessly
|
||||
under that account — fall back to having the panel pre-build the preview comp in
|
||||
the artist's AEP (then it is pure `aerender`), or the ffmpeg engine.
|
||||
|
||||
## What it does (Phase 2 scope)
|
||||
|
||||
- Registers on startup (E6) and receives server-supplied tuning (poll/heartbeat/lease/stall).
|
||||
- Heartbeats every 30 s (E7) — the heartbeat response is also the cancel channel.
|
||||
- Claims one `AE_RENDER` job at a time (E8), runs `aerender.exe` with the manifest's
|
||||
comp/frame-range/templates, parses `PROGRESS:` lines, reports progress + ETA (E9,
|
||||
renews the lease).
|
||||
- Stall watchdog: no progress for `stallTimeoutSeconds` (default 600) → kill process tree, retryable fail.
|
||||
- Deterministic errors (missing footage / missing comp / unopenable project) → non-retryable fail (E10);
|
||||
transient errors auto-requeue server-side up to `maxAttempts`.
|
||||
- On success: uploads the full aerender log via presign (E20), reports complete (E11).
|
||||
- Crash recovery: `current-job.json` written on claim; on restart the worker asks the server
|
||||
what became of the job and reports a retryable fail if it was still ours. Partial renders
|
||||
are never resumed — the next attempt clears its own output files and re-renders.
|
||||
- Durable reporting: complete/fail reports spool to disk and replay in order with backoff —
|
||||
rendering continues while the server is down.
|
||||
- **Validation (Phase 3)**: after a successful render the worker checks the sequence it just
|
||||
produced and reports every result in one call (E12). See below.
|
||||
|
||||
## Validation stage
|
||||
|
||||
Runs on the worker straight after the render, while it still holds the job and the files are
|
||||
local. The Export sits in `VALIDATING` until the report lands; the server decides what the
|
||||
results mean (any FAIL → `VALIDATION_FAILED`, otherwise the preview stage is queued).
|
||||
|
||||
Checks: `file_count`, `missing_frames`, `duplicate_frames`, `first_frame`, `last_frame`,
|
||||
`filename_consistency`, `zero_byte`, `corrupt`, `resolution`, `bit_depth`, `alpha_channel`,
|
||||
`exr_type`, `timecode_plate_match`, `timecode_consistency`, `timecode_fps`.
|
||||
|
||||
**No oiiotool needed.** Everything is read from the EXR header — one 64 KB read per sampled
|
||||
frame, no pixel decode. Full-sequence checks (count, gaps, names, sizes) touch every file;
|
||||
header checks sample first/middle/last plus every Nth frame (`validation.sampleEvery`).
|
||||
|
||||
**Timecode is the primary check** (spec §8.1a) and is compared against *the plate*, in this
|
||||
order: the timing plate named in the manifest → a plate found under `plateSearchRoots` →
|
||||
the API timecode minus handles, which is **WARN-only** because the API stores the editorial
|
||||
in-point and is a handle-length ahead of the plate by design.
|
||||
|
||||
Checksums are xxHash64 per file, with a sequence digest stored on the Export and reused by
|
||||
the delivery builder to verify what it packaged.
|
||||
|
||||
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
|
||||
folder, hard-linking (or copying) each shot's EXRs and delivery MOV into it,
|
||||
verifying every file against the validation-time checksum, and writing
|
||||
`manifest.json` plus the CSV report.
|
||||
|
||||
No After Effects involved, so these are claimed **even while an artist has AE
|
||||
open** — and at priority 10, inside the urgent threshold, so they run during the
|
||||
day rather than waiting for the render window. Someone is usually waiting on the
|
||||
folder.
|
||||
|
||||
`deliveryRoot` has to be reachable from this machine under the service account:
|
||||
`--check` verifies `pathMappings` targets, which is where a build most often
|
||||
falls over.
|
||||
|
||||
Full workflow: [DELIVERY_WORKFLOW.md](../DELIVERY_WORKFLOW.md).
|
||||
|
||||
### Checking a render by hand
|
||||
|
||||
```powershell
|
||||
.\VFXReviewWorker.exe --validate "V:\_EXPORTS\UNG\102\UNG_102_019_010\v005" --plate "V:\_FOOTAGE\UNG\UNG_102\UNG_102_019_010_BG01_TT_v001\UNG_102_019_010_BG01_TT_v001.01001.exr"
|
||||
```
|
||||
|
||||
Runs the whole engine over an existing folder with no server, job or queue, and prints the
|
||||
report. The frame range, pattern and resolution are inferred from the files. Exits non-zero
|
||||
if any check fails, so it also works in a script. Useful for auditing work that predates the
|
||||
pipeline, and for proving the checks on real shots before turning the stage on.
|
||||
|
||||
Preview generation (Phase 4) plugs into this same service.
|
||||
|
||||
## Packaging for another machine
|
||||
|
||||
On a machine with the .NET 8+ SDK (only the packaging machine needs it):
|
||||
|
||||
```powershell
|
||||
.\scripts\publish.ps1 -Zip
|
||||
```
|
||||
|
||||
Produces `publish\win-x64\` containing exactly two things: a self-contained
|
||||
`VFXReviewWorker.exe` (~67 MB, no .NET runtime needed on the target) and
|
||||
`scripts\vfxr_build_preview.jsx`, which the exe loads from its own folder at
|
||||
runtime. **Keep them together** — the script is deliberately not embedded in the
|
||||
exe so the slate/preview build can be patched without a rebuild. `-Zip` also
|
||||
writes `VFXReviewWorker_<date>.zip` for copying.
|
||||
|
||||
### Installing on the target
|
||||
|
||||
1. Copy the folder to the render machine, e.g. `C:\pipeline\VFXReviewWorker`.
|
||||
2. Create `C:\ProgramData\VFXReviewWorker\config.json` (next section).
|
||||
3. **Verify before installing the service:**
|
||||
|
||||
```powershell
|
||||
.\VFXReviewWorker.exe --check
|
||||
```
|
||||
|
||||
This resolves every path, confirms `aerender.exe` / `AfterFX.com` / the build
|
||||
script exist, checks each `pathMappings` target is reachable *from that
|
||||
machine*, and pings the server to confirm the API key is accepted. It exits
|
||||
non-zero if anything would stop the worker running, so a wrong path is caught
|
||||
here instead of in the logs an hour later.
|
||||
|
||||
4. Install the service (below), then confirm the machine appears on the web
|
||||
**Pipeline → Machines** page.
|
||||
|
||||
Run `--check` again under the *service account* (`runas /user:STUDIO\svc-render`)
|
||||
if the service will run as someone other than the logged-in artist — mapped
|
||||
drives and share permissions differ per account, and that is the most common
|
||||
cause of a worker that registers fine but cannot find footage.
|
||||
|
||||
Upgrading later: stop the service, replace the folder, start it again. Config
|
||||
lives in ProgramData and is untouched.
|
||||
|
||||
## Configure
|
||||
|
||||
Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8):
|
||||
|
||||
```json
|
||||
{
|
||||
"serverUrl": "https://review.twotalesvfx.com",
|
||||
"apiKey": "<API_SECRET_KEY>",
|
||||
"machineName": "RENDER-01",
|
||||
"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:/" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
There is no `workerVersion` key: the version is stamped into the exe at build time and
|
||||
read back from the assembly, so a machine can never report a version it is not running.
|
||||
`publish.ps1` appends the git commit, giving `1.1.0+feb1c7b`. Bump `<Version>` in
|
||||
`VFXReviewWorker.csproj` when publishing a change; the worker reports it on startup and
|
||||
**Pipeline → Machines** flags any machine still on an older one.
|
||||
|
||||
`pathMappings` translate the manifest's canonical UNC paths to this machine's drive
|
||||
mappings. `plateSearchRoots` is optional: it is only consulted when a manifest carries no
|
||||
timing plate (older panel versions), to find the shot's plates for the timecode check.
|
||||
Everything tunable (poll interval, lease, stall timeout, max attempts, validation) lives in
|
||||
the server's SystemConfig and arrives at registration — no per-machine tuning files.
|
||||
|
||||
## Run interactively (first-time smoke test)
|
||||
|
||||
```bash
|
||||
VFXReviewWorker.exe
|
||||
```
|
||||
|
||||
Logs go to the console-less service log at `%ProgramData%\VFXReviewWorker\logs\worker_YYYYMMDD.log`
|
||||
(14-day rolling). Confirm the machine appears on the web **Pipeline → Machines** page, then stop it.
|
||||
|
||||
## Install as a Windows service
|
||||
|
||||
Run as a studio account with SAN access (works whether or not an artist is logged in):
|
||||
|
||||
```powershell
|
||||
sc.exe create VFXReviewRenderWorker binPath= "C:\pipeline\VFXReviewWorker\VFXReviewWorker.exe" start= auto obj= "STUDIO\svc-render" password= "<password>"
|
||||
sc.exe description VFXReviewRenderWorker "VFXReview render pipeline worker (aerender)"
|
||||
sc.exe start VFXReviewRenderWorker
|
||||
```
|
||||
|
||||
Uninstall: `sc.exe stop VFXReviewRenderWorker && sc.exe delete VFXReviewRenderWorker`.
|
||||
|
||||
## Rollout notes (spec §17.2)
|
||||
|
||||
- Install on **one** workstation first; add the second after a clean week.
|
||||
- Set `render.maxAttempts = 1` in SystemConfig for the first week (observe before auto-retrying).
|
||||
- Enter each machine's render windows on the Machines page (`availability`), defaults:
|
||||
weekdays 19:00–08:00 + full weekends; Render Now override = 4 h.
|
||||
- The dashboard warns when the two workstations report different AE versions.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd RenderWorker
|
||||
dotnet test
|
||||
```
|
||||
|
||||
Covers the progress parser against recorded aerender transcript lines (success, error,
|
||||
non-retryable), ETA math, path mapping, log-tail ring buffer, and the output-dir
|
||||
clearing guard (only files matching the job's own pattern prefix are ever deleted).
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text;
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Blender writes its render status with a carriage return so it overwrites one
|
||||
/// line in the terminal, and only emits a newline when a frame is saved. A
|
||||
/// reader that splits on newlines alone therefore sees nothing at all while a
|
||||
/// long frame renders — which had the stall watchdog killing healthy jobs.
|
||||
/// </summary>
|
||||
public class BlenderProgressParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Fra:1042 Mem:512.00M (Peak 640.00M) | Time:00:12.34 | Path Tracing Sample 12/128", 1042)]
|
||||
[InlineData("Fra:1 Mem:64.00M | Syncing Cube", 1)]
|
||||
[InlineData("Saved: 'X:/renders/frame_1042.exr'", null)]
|
||||
[InlineData("Blender quit", null)]
|
||||
public void ParsesTheFrameBeingRendered(string line, int? expected)
|
||||
{
|
||||
Assert.Equal(expected, BlenderProgressParser.ParseFrame(line));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Saved: 'X:/renders/frame_1042.exr'", "X:/renders/frame_1042.exr")]
|
||||
[InlineData(" Saved: 'nope'", null)] // leading space: not the real form
|
||||
[InlineData("Fra:1042 Mem:512.00M", null)]
|
||||
public void ParsesSavedFrames(string line, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, BlenderProgressParser.ParseSaved(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TreatsAMissingBlendAsNonRetryable()
|
||||
{
|
||||
const string line = "Error: Cannot read file 'X:/gone.blend': No such file or directory";
|
||||
Assert.True(BlenderProgressParser.IsErrorLine(line));
|
||||
Assert.True(BlenderProgressParser.IsNonRetryableError(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TreatsAnUnknownFailureAsRetryable()
|
||||
{
|
||||
const string line = "Error: CUDA device lost, out of memory";
|
||||
Assert.True(BlenderProgressParser.IsErrorLine(line));
|
||||
Assert.False(BlenderProgressParser.IsNonRetryableError(line));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The regression itself: a realistic stdout chunk where progress is
|
||||
/// separated by CR and only the saved lines end in LF.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CarriageReturnProgressIsSeenWithoutWaitingForANewline()
|
||||
{
|
||||
var stdout =
|
||||
"Fra:1001 Mem:64.00M | Sample 1/128\r" +
|
||||
"Fra:1001 Mem:120.00M | Sample 64/128\r" +
|
||||
"Fra:1001 Mem:128.00M | Sample 128/128\r" +
|
||||
"\nSaved: 'X:/renders/frame_1001.exr'\n" +
|
||||
"Fra:1002 Mem:128.00M | Sample 1/128\r";
|
||||
|
||||
var lines = SplitLikeThePump(stdout);
|
||||
|
||||
var frames = lines.Select(BlenderProgressParser.ParseFrame).Where(f => f is not null).ToList();
|
||||
var saved = lines.Select(BlenderProgressParser.ParseSaved).Where(s => s is not null).ToList();
|
||||
|
||||
// Four status updates and one saved frame; splitting on "\n" alone
|
||||
// would have yielded a single unusable blob and no progress at all.
|
||||
Assert.Equal(4, frames.Count);
|
||||
Assert.Single(saved);
|
||||
Assert.Equal(1002, frames[^1]);
|
||||
}
|
||||
|
||||
/// <summary>Mirrors BlenderRunner.PumpAsync: CR and LF both end a line.</summary>
|
||||
private static List<string> SplitLikeThePump(string text)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
foreach (var c in text)
|
||||
{
|
||||
if (c == '\r' || c == '\n')
|
||||
{
|
||||
if (current.Length > 0) { lines.Add(current.ToString()); current.Clear(); }
|
||||
}
|
||||
else current.Append(c);
|
||||
}
|
||||
if (current.Length > 0) lines.Add(current.ToString());
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Packaging is the last step before files leave the studio, so the parts that
|
||||
/// could silently ship the wrong thing — checksum verification, frame-count
|
||||
/// drift, hard-link vs copy — are pinned here.
|
||||
/// </summary>
|
||||
public class DeliveryStageTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"vfxr_delivery_{Guid.NewGuid():N}");
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _packageDir;
|
||||
|
||||
public DeliveryStageTests()
|
||||
{
|
||||
_sourceDir = Path.Combine(_root, "renders", "SHOT_001", "v001");
|
||||
_packageDir = Path.Combine(_root, "DELIVERY", "101", "260813_Delivery");
|
||||
Directory.CreateDirectory(_sourceDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private void WriteFrames(int count, int startFrame = 1001)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var name = $"SHOT_001_cmp_TT_v001.{startFrame + i:D5}.exr";
|
||||
File.WriteAllText(Path.Combine(_sourceDir, name), $"frame {startFrame + i} pixels");
|
||||
}
|
||||
}
|
||||
|
||||
private DeliveryManifest Manifest(string? checksum = null, int fileCount = 3, string copyMode = "auto") => new()
|
||||
{
|
||||
PackageId = "pkg1",
|
||||
PackageName = "260813_Delivery",
|
||||
PackageNumber = 1,
|
||||
Episode = "101",
|
||||
PackageDir = _packageDir,
|
||||
ReportFile = Path.Combine(_packageDir, "260813_Delivery_report.csv"),
|
||||
CopyMode = copyMode,
|
||||
Items = new List<DeliveryManifestItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ItemId = "item1",
|
||||
ExportId = "exp1",
|
||||
ShotCode = "SHOT_001",
|
||||
VersionString = "v001",
|
||||
SourceDir = _sourceDir,
|
||||
OutputPattern = "SHOT_001_cmp_TT_v001.[#####].exr",
|
||||
FrameStart = 1001,
|
||||
FrameEnd = 1000 + fileCount,
|
||||
FileCount = fileCount,
|
||||
Checksum = checksum,
|
||||
ShotDir = Path.Combine(_packageDir, "SHOT_001"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
private DeliveryStage NewStage() => new(
|
||||
new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" },
|
||||
new ApiClient(new WorkerOptions { ServerUrl = "http://localhost:1", ApiKey = "x" }, NullLogger<ApiClient>.Instance),
|
||||
NullLogger<DeliveryStage>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task PackagesFramesAndWritesReport()
|
||||
{
|
||||
WriteFrames(3);
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(), "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var item = Assert.Single(result.Items);
|
||||
Assert.Equal("VERIFIED", item.Status);
|
||||
Assert.Equal(3, item.FileCount);
|
||||
|
||||
var delivered = Directory.GetFiles(Path.Combine(_packageDir, "SHOT_001"), "*.exr");
|
||||
Assert.Equal(3, delivered.Length);
|
||||
Assert.True(File.Exists(Path.Combine(_packageDir, "manifest.json")));
|
||||
|
||||
var report = File.ReadAllText(Path.Combine(_packageDir, "260813_Delivery_report.csv"));
|
||||
Assert.Contains("Shot,Version,FrameStart", report);
|
||||
Assert.Contains("SHOT_001,v001,1001,1003,3,3", report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HardLinksOnTheSameVolumeSoNoExtraBytesAreWritten()
|
||||
{
|
||||
WriteFrames(2);
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 2), "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.Equal("hardlink", result.Items[0].LinkMode);
|
||||
|
||||
// A hard link is the same file: writing through one name is visible via
|
||||
// the other. That is what makes packaging near-instant and free.
|
||||
var source = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01001.exr");
|
||||
var linked = Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr");
|
||||
File.WriteAllText(source, "rewritten");
|
||||
Assert.Equal("rewritten", File.ReadAllText(linked));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlwaysCopyModeProducesIndependentFiles()
|
||||
{
|
||||
WriteFrames(2);
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 2, copyMode: "always-copy"),
|
||||
"machine1", CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.Equal("copy", result.Items[0].LinkMode);
|
||||
|
||||
var source = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01001.exr");
|
||||
var copied = Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr");
|
||||
File.WriteAllText(source, "rewritten");
|
||||
Assert.NotEqual("rewritten", File.ReadAllText(copied));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChecksumMismatchFailsACopiedItem()
|
||||
{
|
||||
WriteFrames(3);
|
||||
var manifest = Manifest(checksum: "deadbeefdeadbeef", copyMode: "always-copy");
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Equal("FAILED", result.Items[0].Status);
|
||||
Assert.Contains("Checksum mismatch", result.Items[0].ErrorMessage!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HardLinkedItemsAreNotReadBack()
|
||||
{
|
||||
// A hard link is the same data as the source, so re-hashing it re-reads
|
||||
// the exact bytes validation already hashed — half an hour per episode
|
||||
// over SMB to prove something that cannot be false. A deliberately wrong
|
||||
// recorded checksum is therefore not consulted on the linked path, and
|
||||
// the validation-time digest carries over unchanged.
|
||||
WriteFrames(3);
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" },
|
||||
Manifest(checksum: "deadbeefdeadbeef"), "machine1", CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("VERIFIED", result.Items[0].Status);
|
||||
Assert.Equal("hardlink", result.Items[0].LinkMode);
|
||||
Assert.Equal("deadbeefdeadbeef", result.Items[0].Checksum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlwaysHashForcesReadBackEvenWhenLinked()
|
||||
{
|
||||
WriteFrames(3);
|
||||
var manifest = Manifest(checksum: "deadbeefdeadbeef");
|
||||
manifest.VerifyMode = "always-hash";
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("Checksum mismatch", result.Items[0].ErrorMessage!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SourceTouchedAfterValidationFailsTheLinkedItem()
|
||||
{
|
||||
// The one thing linking cannot rule out: the source changing after it
|
||||
// passed QC. The link would faithfully carry the new bytes.
|
||||
WriteFrames(3);
|
||||
var manifest = Manifest();
|
||||
manifest.Items[0].ValidatedAt = DateTimeOffset.UtcNow.AddHours(-2);
|
||||
File.SetLastWriteTimeUtc(
|
||||
Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.01002.exr"), DateTime.UtcNow);
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("modified after validation", result.Items[0].ErrorMessage!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UntouchedSourcesPassTheValidationTimestampCheck()
|
||||
{
|
||||
WriteFrames(3);
|
||||
var manifest = Manifest();
|
||||
manifest.Items[0].ValidatedAt = DateTimeOffset.UtcNow.AddHours(1);
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FrameCountDriftSinceQcFailsTheItem()
|
||||
{
|
||||
// The export recorded 3 frames at validation; the folder now has 2.
|
||||
WriteFrames(2);
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, Manifest(fileCount: 3), "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("the render changed since QC", result.Items[0].ErrorMessage!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingSourceFolderFailsWithThePath()
|
||||
{
|
||||
var manifest = Manifest();
|
||||
manifest.Items[0].SourceDir = Path.Combine(_root, "does-not-exist");
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("Source folder not found", result.Items[0].ErrorMessage!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChecksumMatchesTheValidationTimeDigestForIdenticalContent()
|
||||
{
|
||||
WriteFrames(3);
|
||||
var first = await NewStage().RunAsync(new ClaimedJob { Id = "job1" },
|
||||
Manifest(copyMode: "always-copy"), "machine1", CancellationToken.None, CancellationToken.None);
|
||||
|
||||
// Feeding the recorded digest back in must verify, which is exactly what
|
||||
// the delivery builder does against Export.checksum on the copy path.
|
||||
Directory.Delete(_packageDir, recursive: true);
|
||||
var second = await NewStage().RunAsync(new ClaimedJob { Id = "job1" },
|
||||
Manifest(checksum: first.Items[0].Checksum, copyMode: "always-copy"), "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(second.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoClientCodeKeepsTheInternalRenderFilenames()
|
||||
{
|
||||
// Regression pin: DeliveredBaseName defaults to "" for a shot with no
|
||||
// client code, and delivery must render byte-identical to before this
|
||||
// feature existed.
|
||||
WriteFrames(2);
|
||||
var manifest = Manifest(fileCount: 2);
|
||||
Assert.Equal("", manifest.Items[0].DeliveredBaseName);
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01001.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001.01002.exr")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCodeRenamesDeliveredFramesPreservingNumberAndPadding()
|
||||
{
|
||||
WriteFrames(3, startFrame: 1001);
|
||||
var manifest = Manifest(fileCount: 3);
|
||||
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var deliveredDir = Path.Combine(_packageDir, "SHOT_001");
|
||||
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01001.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01002.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.01003.exr")));
|
||||
// The internal shotCode-based name must not leak into the delivery.
|
||||
Assert.False(File.Exists(Path.Combine(deliveredDir, "SHOT_001_cmp_TT_v001.01001.exr")));
|
||||
Assert.Equal(3, Directory.GetFiles(deliveredDir, "*.exr").Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCodeRenamesTheDeliveryMov()
|
||||
{
|
||||
WriteFrames(2);
|
||||
var movSrc = Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001.mov");
|
||||
File.WriteAllText(movSrc, "mov bytes");
|
||||
|
||||
var manifest = Manifest(fileCount: 2);
|
||||
manifest.IncludeMovs = true;
|
||||
manifest.Items[0].DeliveryMovPath = movSrc;
|
||||
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var deliveredDir = Path.Combine(_packageDir, "SHOT_001");
|
||||
Assert.True(File.Exists(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.mov")));
|
||||
Assert.Equal(Path.Combine(deliveredDir, "SNV_101_0461_CP_CAMF_PL_v001.mov"), result.Items[0].MovPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCodeRenameStillVerifiesAgainstTheValidationTimeChecksum()
|
||||
{
|
||||
// Renaming must not change file *content*, so a checksum recorded at
|
||||
// validation time (against the internal name) has to still match the
|
||||
// delivered (renamed) files — the hash is content-based, not name-based.
|
||||
WriteFrames(3);
|
||||
var manifest = Manifest(copyMode: "always-copy");
|
||||
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
var first = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
Assert.True(first.Ok);
|
||||
|
||||
Directory.Delete(_packageDir, recursive: true);
|
||||
var replay = Manifest(checksum: first.Items[0].Checksum, copyMode: "always-copy");
|
||||
replay.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
var second = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, replay, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(second.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnparseableFrameNumberFallsBackToTheSourceFilename()
|
||||
{
|
||||
// A file that doesn't match the render's own numbering convention
|
||||
// (shouldn't happen for a real render, but must never crash a delivery)
|
||||
// is delivered under its original name rather than failing the item.
|
||||
Directory.CreateDirectory(_sourceDir);
|
||||
File.WriteAllText(Path.Combine(_sourceDir, "SHOT_001_cmp_TT_v001_no_frame_number.exr"), "junk");
|
||||
var manifest = Manifest(fileCount: 1);
|
||||
manifest.Items[0].OutputPattern = "SHOT_001_cmp_TT_v001_no_frame_number.exr";
|
||||
manifest.Items[0].DeliveredBaseName = "SNV_101_0461_CP_CAMF_PL_v001";
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.True(File.Exists(Path.Combine(_packageDir, "SHOT_001", "SHOT_001_cmp_TT_v001_no_frame_number.exr")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The timecode check is the reason the validation engine exists (§8.1a), so
|
||||
/// the header parse and the timecode arithmetic are pinned here against a
|
||||
/// synthetic EXR built byte by byte.
|
||||
/// </summary>
|
||||
public class ExrHeaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReadsTimecodeResolutionChannelsAndFps()
|
||||
{
|
||||
var path = ExrFixture.Write(timecode: "18:58:57:12", width: 1920, height: 1080,
|
||||
channels: new[] { ("A", 1), ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 4);
|
||||
try
|
||||
{
|
||||
var header = ExrHeader.Read(path);
|
||||
|
||||
Assert.Equal(new Timecode(18, 58, 57, 12, false), header.Timecode);
|
||||
Assert.Equal(1920, header.DataWidth);
|
||||
Assert.Equal(1080, header.DataHeight);
|
||||
Assert.Equal(24, header.FramesPerSecond);
|
||||
Assert.Equal("half", header.BitDepth);
|
||||
Assert.True(header.HasAlpha);
|
||||
Assert.Equal("PIZ", header.CompressionName);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTimecodeIsReportedAsAbsentNotAsZero()
|
||||
{
|
||||
var path = ExrFixture.Write(timecode: null, width: 1920, height: 1080,
|
||||
channels: new[] { ("B", 1), ("G", 1), ("R", 1) }, fpsNum: 24, compression: 3);
|
||||
try
|
||||
{
|
||||
var header = ExrHeader.Read(path);
|
||||
Assert.Null(header.Timecode);
|
||||
Assert.False(header.HasAlpha);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonExrFileThrows()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, "this is not an exr at all, not even close");
|
||||
Assert.Throws<InvalidDataException>(() => ExrHeader.Read(path));
|
||||
Assert.Null(ExrHeader.TryRead(path));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// The BCD packing means the raw value reads back as its own hex digits.
|
||||
[InlineData(0x18585712u, 18, 58, 57, 12)]
|
||||
[InlineData(0x00004117u, 0, 0, 41, 17)]
|
||||
[InlineData(0x00000000u, 0, 0, 0, 0)]
|
||||
public void DecodesPackedSmpteTimecode(uint packed, int hh, int mm, int ss, int ff)
|
||||
{
|
||||
Assert.Equal(new Timecode(hh, mm, ss, ff, false), Timecode.FromPacked(packed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameArithmeticRoundTrips()
|
||||
{
|
||||
var tc = new Timecode(18, 58, 57, 12, false);
|
||||
Assert.Equal(tc, Timecode.FromFrames(tc.ToFrames(24), 24));
|
||||
|
||||
// 133 frames on from 18:58:57:12 at 24 fps is 18:59:03:01 — the real
|
||||
// first/last frame pair measured on UNG_102_019_010 v005.
|
||||
Assert.Equal(new Timecode(18, 59, 3, 1, false), Timecode.FromFrames(tc.ToFrames(24) + 133, 24));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiTimecodeIsOneHandleAheadOfThePlate()
|
||||
{
|
||||
// The defect class from §8.1a: the API stores the editorial in-point and
|
||||
// the plate starts 8 frames earlier, so a comp anchored to the API value
|
||||
// renders 8 frames late.
|
||||
var plate = Timecode.Parse("13:44:06:02")!.Value;
|
||||
var api = Timecode.Parse("13:44:06:10")!.Value;
|
||||
Assert.Equal(8, api.ToFrames(24) - plate.ToFrames(24));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameDerivedTimecodeIsFrameNumberOverFps()
|
||||
{
|
||||
// 00:00:41:17 is frame 1001 at 24 fps — the signature of a comp built
|
||||
// with no source timecode at all.
|
||||
Assert.Equal(1001, Timecode.Parse("00:00:41:17")!.Value.ToFrames(24));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("UNG_102_019_010_cmp_TT_v005.01001.exr", 1001)]
|
||||
[InlineData("UNG_108_030_010_cmp_TT_v003.01204.exr", 1204)]
|
||||
// AE-written renders separate the frame with an underscore, and turn up as
|
||||
// layers in a shot comp alongside dot-separated delivered plates.
|
||||
[InlineData("UNG_110_030_010_PRERENDER_01001.exr", 1001)]
|
||||
[InlineData("UNG_110_030_010_render_1001.exr", 1001)]
|
||||
[InlineData("no_frame_number.exr", null)]
|
||||
// A version suffix is not a frame number.
|
||||
[InlineData("UNG_110_030_010_BG01_TT_v001.exr", null)]
|
||||
public void ParsesFrameNumbersFromFilenames(string name, int? expected)
|
||||
{
|
||||
Assert.Equal(expected, SequencePattern.FrameFromName(name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesOutputPattern()
|
||||
{
|
||||
var p = SequencePattern.FromOutputPattern("UNG_102_019_010_cmp_TT_v005.[#####].exr");
|
||||
Assert.Equal("UNG_102_019_010_cmp_TT_v005", p.Prefix);
|
||||
Assert.Equal(".exr", p.Extension);
|
||||
Assert.Equal(5, p.Padding);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds a minimal but structurally real EXR header on disk.</summary>
|
||||
internal static class ExrFixture
|
||||
{
|
||||
public static string Write(string? timecode, int width, int height,
|
||||
(string Name, int PixelType)[] channels, int fpsNum, byte compression)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"vfxr_test_{Guid.NewGuid():N}.exr");
|
||||
using var fs = new FileStream(path, FileMode.Create, FileAccess.Write);
|
||||
using var w = new BinaryWriter(fs);
|
||||
|
||||
w.Write(ExrHeader.Magic);
|
||||
w.Write(2); // version 2, no flags
|
||||
|
||||
// channels (chlist)
|
||||
using (var chData = new MemoryStream())
|
||||
using (var cw = new BinaryWriter(chData))
|
||||
{
|
||||
foreach (var (name, pixelType) in channels)
|
||||
{
|
||||
WriteCString(cw, name);
|
||||
cw.Write(pixelType);
|
||||
cw.Write((byte)0); // pLinear
|
||||
cw.Write(new byte[3]); // reserved
|
||||
cw.Write(1); // xSampling
|
||||
cw.Write(1); // ySampling
|
||||
}
|
||||
cw.Write((byte)0); // list terminator
|
||||
WriteAttr(w, "channels", "chlist", chData.ToArray());
|
||||
}
|
||||
|
||||
WriteAttr(w, "compression", "compression", new[] { compression });
|
||||
|
||||
var window = new byte[16];
|
||||
BitConverter.GetBytes(0).CopyTo(window, 0);
|
||||
BitConverter.GetBytes(0).CopyTo(window, 4);
|
||||
BitConverter.GetBytes(width - 1).CopyTo(window, 8);
|
||||
BitConverter.GetBytes(height - 1).CopyTo(window, 12);
|
||||
WriteAttr(w, "dataWindow", "box2i", window);
|
||||
WriteAttr(w, "displayWindow", "box2i", window);
|
||||
|
||||
var rational = new byte[8];
|
||||
BitConverter.GetBytes(fpsNum).CopyTo(rational, 0);
|
||||
BitConverter.GetBytes(1u).CopyTo(rational, 4);
|
||||
WriteAttr(w, "framesPerSecond", "rational", rational);
|
||||
|
||||
if (timecode is not null)
|
||||
{
|
||||
var parts = timecode.Split(':');
|
||||
uint Bcd(int v) => (uint)((v / 10) * 16 + v % 10);
|
||||
var packed = (Bcd(int.Parse(parts[0])) << 24) | (Bcd(int.Parse(parts[1])) << 16)
|
||||
| (Bcd(int.Parse(parts[2])) << 8) | Bcd(int.Parse(parts[3]));
|
||||
var tcData = new byte[8];
|
||||
BitConverter.GetBytes(packed).CopyTo(tcData, 0);
|
||||
WriteAttr(w, "timeCode", "timecode", tcData);
|
||||
WriteAttr(w, "timeCodeString", "string", System.Text.Encoding.ASCII.GetBytes(timecode));
|
||||
}
|
||||
|
||||
w.Write((byte)0); // end of header
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void WriteAttr(BinaryWriter w, string name, string type, byte[] data)
|
||||
{
|
||||
WriteCString(w, name);
|
||||
WriteCString(w, type);
|
||||
w.Write(data.Length);
|
||||
w.Write(data);
|
||||
}
|
||||
|
||||
private static void WriteCString(BinaryWriter w, string s)
|
||||
{
|
||||
w.Write(System.Text.Encoding.ASCII.GetBytes(s));
|
||||
w.Write((byte)0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// MOV review packages (RenderPipeline2 §11.6). A review folder is opened
|
||||
/// directly by a producer, so the things pinned here are the ones they would
|
||||
/// notice: it is flat, it carries the client's own naming, and it contains the
|
||||
/// MOVs and nothing else.
|
||||
/// </summary>
|
||||
public class MovReviewStageTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"vfxr_review_{Guid.NewGuid():N}");
|
||||
private readonly string _renderDir;
|
||||
private readonly string _reviewDir;
|
||||
|
||||
public MovReviewStageTests()
|
||||
{
|
||||
_renderDir = Path.Combine(_root, "renders", "SHOT_001", "v004");
|
||||
_reviewDir = Path.Combine(_root, "DELIVERY", "PRODUCER_REVIEWS", "20260828_Producer_review");
|
||||
Directory.CreateDirectory(_renderDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private string WriteMov(string shotCode, string version, string contents = "mov bytes")
|
||||
{
|
||||
var path = Path.Combine(_renderDir, $"{shotCode}_cmp_TT_{version}.mov");
|
||||
File.WriteAllText(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
private DeliveryManifest Manifest(params DeliveryManifestItem[] items) => new()
|
||||
{
|
||||
Kind = "MOV_REVIEW",
|
||||
PackageId = "pkg1",
|
||||
PackageName = "20260828_Producer_review",
|
||||
PackageNumber = 7,
|
||||
Episode = "101",
|
||||
PackageDir = _reviewDir,
|
||||
// A review writes no report; the server keeps the record instead.
|
||||
ReportFile = "",
|
||||
Items = items.ToList(),
|
||||
};
|
||||
|
||||
private DeliveryManifestItem Item(
|
||||
string shotCode, string version, string? movPath, string? deliveredBase = null) => new()
|
||||
{
|
||||
ItemId = $"item_{shotCode}",
|
||||
ExportId = $"exp_{shotCode}",
|
||||
ShotCode = shotCode,
|
||||
VersionString = version,
|
||||
SourceDir = "",
|
||||
OutputPattern = "",
|
||||
FrameStart = 1001,
|
||||
FrameEnd = 1050,
|
||||
DeliveryMovPath = movPath,
|
||||
// Flat: every item's folder is the package folder itself.
|
||||
ShotDir = _reviewDir,
|
||||
DeliveredBaseName = deliveredBase ?? $"{shotCode}_cmp_TT_{version}",
|
||||
};
|
||||
|
||||
private DeliveryStage NewStage() => new(
|
||||
new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" },
|
||||
new ApiClient(new WorkerOptions { ServerUrl = "http://localhost:1", ApiKey = "x" }, NullLogger<ApiClient>.Instance),
|
||||
NullLogger<DeliveryStage>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task BuildsAFlatFolderOfMovsUnderTheClientFacingName()
|
||||
{
|
||||
var mov = WriteMov("SHOT_001", "v004");
|
||||
var manifest = Manifest(Item("SHOT_001", "v004", mov, deliveredBase: "EP6_VFX_012_v004"));
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var item = Assert.Single(result.Items);
|
||||
Assert.Equal("VERIFIED", item.Status);
|
||||
Assert.Equal(1, item.FileCount);
|
||||
// No shot subfolder: the MOVs sit beside each other to be scrubbed through.
|
||||
Assert.Null(item.ExrPath);
|
||||
Assert.Equal(Path.Combine(_reviewDir, "EP6_VFX_012_v004.mov"), item.MovPath);
|
||||
Assert.Single(Directory.GetDirectories(_reviewDir).Concat(Directory.GetFiles(_reviewDir)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeavesNoReportOrManifestBesideTheMovs()
|
||||
{
|
||||
var manifest = Manifest(Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004")));
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Null(result.ReportPath);
|
||||
// What a producer opens is exactly the MOVs — no manifest.json, no CSV.
|
||||
Assert.False(File.Exists(Path.Combine(_reviewDir, "manifest.json")));
|
||||
Assert.All(Directory.GetFiles(_reviewDir), f => Assert.EndsWith(".mov", f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HardLinksSoASeventyShotReviewCostsNoDisk()
|
||||
{
|
||||
var mov = WriteMov("SHOT_001", "v004");
|
||||
var manifest = Manifest(Item("SHOT_001", "v004", mov));
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.Equal("hardlink", result.Items[0].LinkMode);
|
||||
File.WriteAllText(mov, "rewritten");
|
||||
Assert.Equal("rewritten", File.ReadAllText(result.Items[0].MovPath!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlwaysCopyModeProducesAnIndependentFile()
|
||||
{
|
||||
var mov = WriteMov("SHOT_001", "v004");
|
||||
var manifest = Manifest(Item("SHOT_001", "v004", mov));
|
||||
manifest.CopyMode = "always-copy";
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("copy", result.Items[0].LinkMode);
|
||||
File.WriteAllText(mov, "rewritten");
|
||||
Assert.NotEqual("rewritten", File.ReadAllText(result.Items[0].MovPath!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AMissingMovFailsTheReviewRatherThanShippingItShort()
|
||||
{
|
||||
// In an EXR delivery a missing MOV is a missing viewing copy and the
|
||||
// package continues. In a review the MOV *is* the deliverable.
|
||||
var manifest = Manifest(Item("SHOT_001", "v004", Path.Combine(_renderDir, "never_rendered.mov")));
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Equal("FAILED", result.Items[0].Status);
|
||||
Assert.Contains("Preview MOV not found", result.Items[0].ErrorMessage!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnItemWithNoRecordedMovFails()
|
||||
{
|
||||
var manifest = Manifest(Item("SHOT_001", "v004", null));
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("No preview MOV", result.Items[0].ErrorMessage!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HashesEveryFileWhenTheConfigDemandsIt()
|
||||
{
|
||||
var manifest = Manifest(Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004")));
|
||||
manifest.VerifyMode = "always-hash";
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
// A MOV has no validation-time digest of its own, so the one recorded
|
||||
// here is the one this build computed.
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Items[0].Checksum));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneBadShotFailsThePackageAndTheRestStillReport()
|
||||
{
|
||||
var manifest = Manifest(
|
||||
Item("SHOT_001", "v004", WriteMov("SHOT_001", "v004")),
|
||||
Item("SHOT_002", "v002", Path.Combine(_renderDir, "absent.mov")));
|
||||
|
||||
var result = await NewStage().RunAsync(new ClaimedJob { Id = "job1" }, manifest, "machine1",
|
||||
CancellationToken.None, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Equal("VERIFIED", result.Items[0].Status);
|
||||
Assert.Equal("FAILED", result.Items[1].Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
public class PathMapperTests
|
||||
{
|
||||
private static PathMapper Mapper(params (string from, string to)[] maps) =>
|
||||
new(maps.Select(m => new PathMapping { From = m.from, To = m.to }));
|
||||
|
||||
[Fact]
|
||||
public void MapsUncToDriveLetter()
|
||||
{
|
||||
var mapper = Mapper(("//SAN/", "S:/"));
|
||||
Assert.Equal(@"S:\renders\UNG_S1\106\shot\v004", mapper.Map("//SAN/renders/UNG_S1/106/shot/v004"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapsBackslashInputToo()
|
||||
{
|
||||
var mapper = Mapper(("//SAN/", "S:/"));
|
||||
Assert.Equal(@"S:\projects\a.aep", mapper.Map(@"\\SAN\projects\a.aep"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaseInsensitiveMatch()
|
||||
{
|
||||
var mapper = Mapper(("//SAN/", "S:/"));
|
||||
Assert.Equal(@"S:\x", mapper.Map("//san/x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstMatchingMappingWins()
|
||||
{
|
||||
var mapper = Mapper(("//SAN/renders/", "R:/"), ("//SAN/", "S:/"));
|
||||
Assert.Equal(@"R:\a", mapper.Map("//SAN/renders/a"));
|
||||
Assert.Equal(@"S:\projects\a", mapper.Map("//SAN/projects/a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnmappedPathPassesThroughWithLocalSeparators()
|
||||
{
|
||||
var mapper = Mapper(("//SAN/", "S:/"));
|
||||
Assert.Equal(@"D:\local\thing", mapper.Map("D:/local/thing"));
|
||||
}
|
||||
}
|
||||
|
||||
public class PrepareOutputDirTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClearsOnlyFilesMatchingPatternPrefix()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"), "old");
|
||||
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1002.exr"), "old");
|
||||
File.WriteAllText(Path.Combine(dir, "unrelated-notes.txt"), "keep me");
|
||||
|
||||
AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr");
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr")));
|
||||
Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1002.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(dir, "unrelated-notes.txt")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatesMissingDirectory()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N"), "v001");
|
||||
try
|
||||
{
|
||||
AerenderRunner.PrepareOutputDir(dir, "X.[####].exr");
|
||||
Assert.True(Directory.Exists(dir));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(Path.GetDirectoryName(dir)!, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Recorded-transcript tests (§17.2): lines below are representative aerender
|
||||
/// stdout from AE 2024/2025/2026 successes, errors, and stalls.
|
||||
/// </summary>
|
||||
public class ProgressParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("PROGRESS: 0:00:02:03 (51): 0 Seconds", 51)]
|
||||
[InlineData("PROGRESS: 2 (2): 1 Seconds", 2)]
|
||||
[InlineData("PROGRESS: 0:00:00:00 (1): 12 Seconds", 1)]
|
||||
[InlineData("PROGRESS: 0:00:04:23 (120): 3 Seconds", 120)]
|
||||
public void ParsesProgressFrames(string line, int expected)
|
||||
{
|
||||
Assert.Equal(expected, ProgressParser.ParseFrame(line));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PROGRESS: Rendering frame data")]
|
||||
[InlineData("Starting composition \"UNG_106_010_020_cmp\".")]
|
||||
[InlineData("aerender version 24.3x52")]
|
||||
[InlineData("")]
|
||||
public void IgnoresNonFrameLines(string line)
|
||||
{
|
||||
Assert.Null(ProgressParser.ParseFrame(line));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("aerender ERROR: An existing connection was forcibly closed")]
|
||||
[InlineData("aerender ERROR -1610153464: After Effects error")]
|
||||
[InlineData("ERROR: Unable to open project")]
|
||||
[InlineData("aerender SYNTAX ERROR: Illegal argument flag: false")]
|
||||
public void DetectsErrorLines(string line)
|
||||
{
|
||||
Assert.True(ProgressParser.IsErrorLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressLineIsNotAnError()
|
||||
{
|
||||
Assert.False(ProgressParser.IsErrorLine("PROGRESS: 0:00:02:03 (51): 0 Seconds"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("aerender ERROR: After Effects error: layer source file is missing or inaccessible")]
|
||||
[InlineData("aerender ERROR: No comp was found with the given name.")]
|
||||
[InlineData("aerender ERROR: project file could not be opened")]
|
||||
[InlineData("WARNING: Missing footage in comp")]
|
||||
[InlineData("aerender SYNTAX ERROR: Illegal argument flag: false")]
|
||||
public void DeterministicErrorsAreNonRetryable(string line)
|
||||
{
|
||||
Assert.True(ProgressParser.IsNonRetryableError(line));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("aerender ERROR: An existing connection was forcibly closed")]
|
||||
[InlineData("aerender ERROR -1610153464: After Effects crashed")]
|
||||
public void TransientErrorsStayRetryable(string line)
|
||||
{
|
||||
Assert.False(ProgressParser.IsNonRetryableError(line));
|
||||
}
|
||||
}
|
||||
|
||||
public class EtaCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void NoFramesMeansNoEstimate()
|
||||
{
|
||||
var eta = new EtaCalculator();
|
||||
Assert.Null(eta.EstimateSeconds(100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimatesFromRollingAverage()
|
||||
{
|
||||
var eta = new EtaCalculator();
|
||||
var t = DateTimeOffset.UtcNow;
|
||||
eta.RecordFrame(t);
|
||||
eta.RecordFrame(t.AddSeconds(2));
|
||||
eta.RecordFrame(t.AddSeconds(4)); // 2 s/frame average
|
||||
Assert.Equal(20, eta.EstimateSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRemainingMeansNoEstimate()
|
||||
{
|
||||
var eta = new EtaCalculator();
|
||||
var t = DateTimeOffset.UtcNow;
|
||||
eta.RecordFrame(t);
|
||||
eta.RecordFrame(t.AddSeconds(1));
|
||||
Assert.Null(eta.EstimateSeconds(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollingWindowDropsOldFrames()
|
||||
{
|
||||
var eta = new EtaCalculator(windowSize: 2);
|
||||
var t = DateTimeOffset.UtcNow;
|
||||
eta.RecordFrame(t);
|
||||
eta.RecordFrame(t.AddSeconds(100)); // old slow frame, will roll out
|
||||
eta.RecordFrame(t.AddSeconds(101));
|
||||
eta.RecordFrame(t.AddSeconds(102)); // window now [1, 1]
|
||||
Assert.Equal(10, eta.EstimateSeconds(10));
|
||||
}
|
||||
}
|
||||
|
||||
public class LogTailTests
|
||||
{
|
||||
[Fact]
|
||||
public void KeepsOnlyLastNLines()
|
||||
{
|
||||
var tail = new LogTail(3);
|
||||
for (var i = 1; i <= 5; i++) tail.Add($"line {i}");
|
||||
Assert.Equal("line 3\nline 4\nline 5", tail.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The preview MP4 opens on the Netflix slate. ffmpeg's <c>thumbnail</c> filter
|
||||
/// only considers the first 100 frames, so every thumbnail the pipeline has ever
|
||||
/// produced was a picture of the slate card rather than of the shot — invisible
|
||||
/// while thumbnails were only decoration in the QC queue, and unacceptable now
|
||||
/// they can become a shot's thumbnail.
|
||||
///
|
||||
/// The fix is to seek past the slate, so the seek arithmetic is worth pinning.
|
||||
/// </summary>
|
||||
public class ThumbnailSeekTests
|
||||
{
|
||||
private static PreviewBuildResult Build(double? pictureStart, int? frameCount) =>
|
||||
new() { PictureStartSeconds = pictureStart, FrameCount = frameCount };
|
||||
|
||||
[Fact]
|
||||
public void SeeksAQuarterIntoPictureNotIntoTheFile()
|
||||
{
|
||||
// 5s slate, then 96 frames (4s) of picture at 24fps → 5 + 1 = 6s.
|
||||
var seek = PreviewStage.ThumbnailSeekSeconds(Build(5.0, 96), 24);
|
||||
Assert.NotNull(seek);
|
||||
Assert.Equal(6.0, seek!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesATemplateWithNoSlateAtAll()
|
||||
{
|
||||
var seek = PreviewStage.ThumbnailSeekSeconds(Build(0.0, 240), 24);
|
||||
Assert.Equal(2.5, seek!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallsBackWhenTheBuildDidNotReportWherePictureStarts()
|
||||
{
|
||||
// Older worker, or a template with no SHOT layer: null means "keep the
|
||||
// old whole-file behaviour" rather than guessing an offset.
|
||||
Assert.Null(PreviewStage.ThumbnailSeekSeconds(Build(null, 96), 24));
|
||||
Assert.Null(PreviewStage.ThumbnailSeekSeconds(null, 24));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1.0, 96, 24)] // negative slate length
|
||||
[InlineData(5.0, 0, 24)] // no picture frames
|
||||
[InlineData(5.0, -5, 24)] // nonsense frame count
|
||||
[InlineData(5.0, 96, 0)] // no fps to divide by
|
||||
public void RefusesNumbersThatDoNotDescribeARealClip(double start, int frames, double fps)
|
||||
{
|
||||
Assert.Null(PreviewStage.ThumbnailSeekSeconds(Build(start, frames), fps));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VFXReviewWorker\VFXReviewWorker.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression cover for the two coordinate systems a render lives in, taken
|
||||
/// from a real failure on UNG_107_051_010 v004:
|
||||
///
|
||||
/// comp frames 1247792-1247896 (anchored to the plate's timecode 14:26:31:08)
|
||||
/// file numbers 1001-1105 (the output module's starting number)
|
||||
///
|
||||
/// Checking file numbers against comp frames failed every render, and using
|
||||
/// the comp range as the timecode anchor produced a million-frame offset.
|
||||
/// </summary>
|
||||
public class ValidationStageTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"vfxr_val_{Guid.NewGuid():N}");
|
||||
|
||||
public ValidationStageTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>Writes a sequence numbered from 1001 with a plate-derived timecode.</summary>
|
||||
private void WriteSequence(int count, string startTimecode, int firstFrame = 1001, int fps = 24)
|
||||
{
|
||||
var start = Timecode.Parse(startTimecode)!.Value.ToFrames(fps);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var tc = Timecode.FromFrames(start + i, fps);
|
||||
var path = Path.Combine(_dir, $"UNG_107_051_010_cmp_TT_v004.{firstFrame + i:D5}.exr");
|
||||
File.Move(ExrFixture.Write(tc.ToString(), 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, fps, 4), path);
|
||||
}
|
||||
}
|
||||
|
||||
private static RenderManifest Manifest(string dir, string? apiTimecode, int? outputFrameStart) => new()
|
||||
{
|
||||
ShotCode = "UNG_107_051_010",
|
||||
OutputDir = dir,
|
||||
OutputPattern = "UNG_107_051_010_cmp_TT_v004.[#####].exr",
|
||||
// The comp is anchored to the plate timecode, so its frames are absolute.
|
||||
FrameStart = 1247792,
|
||||
FrameEnd = 1247896,
|
||||
OutputFrameStart = outputFrameStart,
|
||||
Fps = 24,
|
||||
Width = 3840,
|
||||
Height = 2160,
|
||||
Expected = apiTimecode is null ? null : new ManifestExpected { TimecodeStart = apiTimecode },
|
||||
};
|
||||
|
||||
private ValidationStage NewStage() =>
|
||||
new(new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" }, NullLogger<ValidationStage>.Instance);
|
||||
|
||||
private static ValidationCheck Check(ValidationOutcome o, string name) =>
|
||||
o.Checks.Single(c => c.CheckName == name);
|
||||
|
||||
[Fact]
|
||||
public void FileNumberingIsCheckedAgainstOutputStartNotCompFrames()
|
||||
{
|
||||
WriteSequence(105, "14:26:31:08");
|
||||
var outcome = NewStage().Run(Manifest(_dir, "14:26:31:16", 1001), _dir, new ServerConfig());
|
||||
|
||||
Assert.Equal("PASS", Check(outcome, "file_count").Status);
|
||||
Assert.Equal("PASS", Check(outcome, "first_frame").Status);
|
||||
Assert.Equal("PASS", Check(outcome, "last_frame").Status);
|
||||
Assert.Equal("PASS", Check(outcome, "missing_frames").Status);
|
||||
Assert.Equal("1105", Check(outcome, "last_frame").Actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiTimecodeIsAnchoredOnTheRenderNotTheCompRange()
|
||||
{
|
||||
// Shot.timecodeStart is the edit in-point; the plate carries 8 frames of
|
||||
// head handle, so the first rendered frame is 8 frames earlier.
|
||||
WriteSequence(105, "14:26:31:08");
|
||||
var outcome = NewStage().Run(Manifest(_dir, "14:26:31:16", 1001), _dir, new ServerConfig());
|
||||
|
||||
var tc = Check(outcome, "timecode_plate_match");
|
||||
// WARN, never PASS: the API is a fallback reference (§8.1a).
|
||||
Assert.Equal("WARN", tc.Status);
|
||||
Assert.Equal("14:26:31:08", tc.Expected);
|
||||
Assert.Equal("14:26:31:08", tc.Actual);
|
||||
Assert.DoesNotContain("frames)", tc.Message ?? "");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealTimecodeDefectStillFailsAgainstAPlate()
|
||||
{
|
||||
// Same shot, but rendered 8 frames late — the handle-offset defect.
|
||||
WriteSequence(105, "14:26:31:16");
|
||||
var plate = Path.Combine(_dir, "plate", "UNG_107_051_010_BG01_TT_v001.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), plate);
|
||||
|
||||
var manifest = Manifest(_dir, "14:26:31:16", 1001);
|
||||
manifest.Source = new ManifestSource
|
||||
{
|
||||
TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 },
|
||||
HandleFrames = 8,
|
||||
};
|
||||
|
||||
var tc = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match");
|
||||
Assert.Equal("FAIL", tc.Status);
|
||||
Assert.Contains("+8 frames", tc.Message);
|
||||
Assert.Contains("8-frame handle", tc.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APlateAtADifferentRateFailsPlateFps()
|
||||
{
|
||||
// The DH2 case: a 25 fps show whose shot records defaulted to 24, so
|
||||
// every comp was built at 24. The render agrees with its own manifest,
|
||||
// so only the plate can reveal it.
|
||||
WriteSequence(105, "14:26:31:08");
|
||||
var plate = Path.Combine(_dir, "plate", "UNG_107_051_010_BG01_TT_v001.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 25, 4), plate);
|
||||
|
||||
var manifest = Manifest(_dir, "14:26:31:16", 1001);
|
||||
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
|
||||
|
||||
var outcome = NewStage().Run(manifest, _dir, new ServerConfig());
|
||||
var fpsCheck = Check(outcome, "plate_fps");
|
||||
Assert.Equal("WARN", fpsCheck.Status);
|
||||
Assert.Equal("25", fpsCheck.Expected);
|
||||
Assert.Equal("24", fpsCheck.Actual);
|
||||
Assert.Contains("wrong rate", fpsCheck.Message);
|
||||
|
||||
// timecode_fps alone would not have caught it: the render and the
|
||||
// manifest agree with each other at 24.
|
||||
Assert.Equal("PASS", Check(outcome, "timecode_fps").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOverCrankedPlateIsRecognisedRatherThanReportedAsAMistake()
|
||||
{
|
||||
// A 50 fps plate on a 25 fps show is deliberate slow motion, conformed
|
||||
// on purpose — real on DH2, and it must not read as a rate error.
|
||||
WriteSequence(105, "14:26:31:08", fps: 25);
|
||||
var plate = Path.Combine(_dir, "plate", "DH2_PL01.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 50, 4), plate);
|
||||
|
||||
var manifest = Manifest(_dir, null, 1001);
|
||||
manifest.Fps = 25;
|
||||
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
|
||||
|
||||
var check = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "plate_fps");
|
||||
Assert.Equal("WARN", check.Status);
|
||||
Assert.Contains("over-crank", check.Message);
|
||||
Assert.DoesNotContain("wrong rate", check.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchingRatesPassPlateFps()
|
||||
{
|
||||
WriteSequence(105, "14:26:31:08");
|
||||
var plate = Path.Combine(_dir, "plate", "UNG_107_051_010_BG01_TT_v001.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:08", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), plate);
|
||||
|
||||
var manifest = Manifest(_dir, "14:26:31:16", 1001);
|
||||
manifest.Source = new ManifestSource { TimingPlate = new PlateRef { Path = plate, FrameStart = 1001 } };
|
||||
|
||||
Assert.Equal("PASS", Check(NewStage().Run(manifest, _dir, new ServerConfig()), "plate_fps").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GapsAreStillCaughtWithinTheRun()
|
||||
{
|
||||
WriteSequence(105, "14:26:31:08");
|
||||
File.Delete(Path.Combine(_dir, "UNG_107_051_010_cmp_TT_v004.01050.exr"));
|
||||
|
||||
var outcome = NewStage().Run(Manifest(_dir, "14:26:31:16", 1001), _dir, new ServerConfig());
|
||||
Assert.Equal("FAIL", Check(outcome, "file_count").Status);
|
||||
Assert.Equal("FAIL", Check(outcome, "missing_frames").Status);
|
||||
Assert.Contains("1050", Check(outcome, "missing_frames").Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldManifestsWithoutOutputNumberingSkipRatherThanFail()
|
||||
{
|
||||
WriteSequence(105, "14:26:31:08");
|
||||
var outcome = NewStage().Run(Manifest(_dir, "14:26:31:16", null), _dir, new ServerConfig());
|
||||
|
||||
Assert.Equal("SKIPPED", Check(outcome, "first_frame").Status);
|
||||
Assert.Equal("PASS", Check(outcome, "missing_frames").Status);
|
||||
Assert.Contains("1001-1105", Check(outcome, "first_frame").Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text;
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Reference vectors from the xxHash specification. The delivery builder
|
||||
/// verifies packaged files against digests recorded here at validation time, so
|
||||
/// a wrong implementation would not be caught by round-tripping our own code —
|
||||
/// only by pinning the published values.
|
||||
/// </summary>
|
||||
public class XxHash64Tests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("", 0xEF46DB3751D8E999UL)]
|
||||
[InlineData("a", 0xD24EC4F1A98C6E5BUL)]
|
||||
[InlineData("abc", 0x44BC2CF5AD770999UL)]
|
||||
public void MatchesReferenceVectors(string input, ulong expected)
|
||||
{
|
||||
Assert.Equal(expected, XxHash64.Hash(Encoding.ASCII.GetBytes(input)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamingInChunksMatchesSingleShot()
|
||||
{
|
||||
var data = new byte[1000];
|
||||
new Random(42).NextBytes(data);
|
||||
var oneShot = XxHash64.Hash(data);
|
||||
|
||||
// Chunk sizes deliberately straddle the 32-byte stripe boundary.
|
||||
foreach (var chunk in new[] { 1, 7, 31, 32, 33, 64, 999 })
|
||||
{
|
||||
var state = new XxHash64.State(0);
|
||||
for (var i = 0; i < data.Length; i += chunk)
|
||||
{
|
||||
state.Update(new ReadOnlySpan<byte>(data, i, Math.Min(chunk, data.Length - i)));
|
||||
}
|
||||
Assert.Equal(oneShot, state.Digest());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashFileIsStableAndContentSensitive()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, "frame data");
|
||||
var first = XxHash64.HashFile(path);
|
||||
Assert.Equal(first, XxHash64.HashFile(path));
|
||||
|
||||
File.WriteAllText(path, "frame datb");
|
||||
Assert.NotEqual(first, XxHash64.HashFile(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Detects an *interactive* After Effects session on this machine.
|
||||
///
|
||||
/// This matters because `AfterFX.com -noui -r script.jsx` is handed to an
|
||||
/// already-running After Effects instance rather than starting an isolated
|
||||
/// one — so running the preview build while an artist has AE open would take
|
||||
/// over their session and quit it, losing unsaved work.
|
||||
///
|
||||
/// Deliberately conservative: this matches any After Effects process, the
|
||||
/// interactive `AfterFX.exe` as well as the `AfterFX.com` render engine that
|
||||
/// aerender drives. Waiting for either to finish also avoids two AE instances
|
||||
/// competing for the same box, and the worst case is only that a preview build
|
||||
/// starts a little later.
|
||||
/// </summary>
|
||||
public static class AeSession
|
||||
{
|
||||
public static bool InteractiveRunning()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Process.GetProcessesByName("AfterFX").Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If we cannot tell, assume an artist is working — never risk their session.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
public sealed record RenderResult(
|
||||
bool Success,
|
||||
int ExitCode,
|
||||
bool Retryable,
|
||||
string? ErrorMessage,
|
||||
string LogTail,
|
||||
string FullLogPath,
|
||||
double RenderSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Stage 1: aerender execution (§7.3). Launches aerender.exe, parses stdout
|
||||
/// for PROGRESS/ERROR lines, reports progress (renewing the lease), enforces
|
||||
/// the stall timeout, and honours cancellation by killing the process tree.
|
||||
/// </summary>
|
||||
public sealed class AerenderRunner
|
||||
{
|
||||
private readonly WorkerOptions _options;
|
||||
private readonly ApiClient _api;
|
||||
private readonly ILogger<AerenderRunner> _log;
|
||||
|
||||
public AerenderRunner(WorkerOptions options, ApiClient api, ILogger<AerenderRunner> log)
|
||||
{
|
||||
_options = options;
|
||||
_api = api;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders every queued item in an already-prepared project (no -comp), used
|
||||
/// by the preview stage: the build script saved an AEP with the MOV and MP4
|
||||
/// output modules already queued, so one launch produces both.
|
||||
/// </summary>
|
||||
public Task<RenderResult> RunProjectAsync(
|
||||
string jobId,
|
||||
string machineId,
|
||||
string projectPathLocal,
|
||||
int totalFrames,
|
||||
ServerConfig config,
|
||||
CancellationToken cancelJob,
|
||||
CancellationToken shutdown)
|
||||
{
|
||||
var args = new List<string> { "-project", projectPathLocal, "-mp" };
|
||||
_log.LogInformation("Rendering prepared project for job {JobId}: {Project}", jobId, projectPathLocal);
|
||||
return ExecuteAsync(jobId, machineId, args, totalFrames, 0, config, $"aerender_{jobId}_preview.log", cancelJob, shutdown);
|
||||
}
|
||||
|
||||
public async Task<RenderResult> RunAsync(
|
||||
ClaimedJob job,
|
||||
RenderManifest m,
|
||||
string machineId,
|
||||
string outputDirLocal,
|
||||
ServerConfig config,
|
||||
CancellationToken cancelJob,
|
||||
CancellationToken shutdown)
|
||||
{
|
||||
var mapper = new PathMapper(_options.PathMappings);
|
||||
var aepLocal = mapper.Map(m.AepPath);
|
||||
var outputArg = Path.Combine(outputDirLocal, m.OutputPattern);
|
||||
|
||||
PrepareOutputDir(outputDirLocal, m.OutputPattern);
|
||||
|
||||
var args = new List<string>
|
||||
{
|
||||
"-project", aepLocal,
|
||||
"-comp", m.CompName,
|
||||
"-s", m.FrameStart.ToString(),
|
||||
"-e", m.FrameEnd.ToString(),
|
||||
};
|
||||
if (!string.IsNullOrEmpty(m.RenderSettingsTemplate)) { args.Add("-RStemplate"); args.Add(m.RenderSettingsTemplate); }
|
||||
if (!string.IsNullOrEmpty(m.OutputModuleTemplate)) { args.Add("-OMtemplate"); args.Add(m.OutputModuleTemplate); }
|
||||
args.Add("-output"); args.Add(outputArg);
|
||||
// NB: no "-continueOnMissingFootage" — the real flag takes no value
|
||||
// (passing "false" is an aerender SYNTAX ERROR), and its *presence*
|
||||
// enables skipping missing footage. aerender's default is to stop on
|
||||
// missing footage, which is exactly what the pipeline wants.
|
||||
|
||||
_log.LogInformation("Launching aerender for job {JobId}: {Aep} comp \"{Comp}\" frames {S}-{E} → {Out}",
|
||||
job.Id, aepLocal, m.CompName, m.FrameStart, m.FrameEnd, outputArg);
|
||||
|
||||
return await ExecuteAsync(job.Id, machineId, args, m.TotalFrames, m.FrameStart, config,
|
||||
$"aerender_{job.Id}.log", cancelJob, shutdown);
|
||||
}
|
||||
|
||||
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.AerenderPath,
|
||||
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;
|
||||
int framesDone = 0;
|
||||
string? firstError = null;
|
||||
bool nonRetryable = false;
|
||||
bool 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 = ProgressParser.ParseFrame(line);
|
||||
if (frame is not null)
|
||||
{
|
||||
framesDone = Math.Max(framesDone, frame.Value);
|
||||
lastProgressAt = DateTimeOffset.UtcNow;
|
||||
eta.RecordFrame(lastProgressAt);
|
||||
}
|
||||
if ((isStderr || ProgressParser.IsErrorLine(line)) && !string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
if (ProgressParser.IsErrorLine(line)) firstError ??= line.Trim();
|
||||
if (ProgressParser.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 aerender at {_options.AerenderPath}: {ex.Message}",
|
||||
tail.ToString(), fullLogPath, 0);
|
||||
}
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
var total = totalFrames;
|
||||
while (!process.HasExited)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None);
|
||||
|
||||
if (cancelJob.IsCancellationRequested || shutdown.IsCancellationRequested)
|
||||
{
|
||||
_log.LogWarning("Cancellation requested — killing aerender tree for job {JobId}", jobId);
|
||||
KillTree(process);
|
||||
cancelledByServer = cancelJob.IsCancellationRequested;
|
||||
break;
|
||||
}
|
||||
|
||||
// Stall watchdog (§7.3): no progress line for stallTimeoutSeconds → kill
|
||||
if ((DateTimeOffset.UtcNow - lastProgressAt).TotalSeconds > config.StallTimeoutSeconds)
|
||||
{
|
||||
_log.LogError("aerender stalled ({Sec}s without progress) — killing job {JobId}", config.StallTimeoutSeconds, jobId);
|
||||
KillTree(process);
|
||||
await WaitForExitSafe(process);
|
||||
return new RenderResult(false, -2, Retryable: true,
|
||||
$"aerender produced no progress for {config.StallTimeoutSeconds}s (stall timeout)",
|
||||
tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
|
||||
}
|
||||
|
||||
// Progress report every ~10 s (renews lease); best-effort
|
||||
if ((DateTimeOffset.UtcNow - lastReportAt).TotalSeconds >= 10)
|
||||
{
|
||||
lastReportAt = DateTimeOffset.UtcNow;
|
||||
var progress = total > 0 ? Math.Min(1.0, (double)framesDone / total) : 0;
|
||||
try
|
||||
{
|
||||
var resp = await _api.ProgressAsync(jobId, machineId, progress,
|
||||
framesDone > 0 ? frameStart + framesDone - 1 : null,
|
||||
total, eta.EstimateSeconds(total - framesDone), tail.ToString(), shutdown);
|
||||
if (resp?.CancelRequested == true)
|
||||
{
|
||||
_log.LogWarning("Server requested cancel for job {JobId}", jobId);
|
||||
KillTree(process);
|
||||
cancelledByServer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "Progress report failed (non-fatal; lease covered by reaper)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 && firstError is null)
|
||||
{
|
||||
return new RenderResult(true, 0, true, null, tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
|
||||
}
|
||||
return new RenderResult(false, exitCode, Retryable: !nonRetryable,
|
||||
firstError ?? $"aerender exited with code {exitCode}",
|
||||
tail.ToString(), fullLogPath, sw.Elapsed.TotalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §7.3: outputDir is created before launch; a pre-existing non-empty dir
|
||||
/// can only hold output from a previous crashed attempt of this same
|
||||
/// immutable version. Safety guard: only files matching the job's own
|
||||
/// output pattern prefix are ever deleted.
|
||||
/// </summary>
|
||||
/// <summary>Filename prefix of an output pattern ("X.[####].exr" → "X").</summary>
|
||||
internal static string OutputPatternPrefix(string outputPattern)
|
||||
{
|
||||
var bracket = outputPattern.IndexOf('[');
|
||||
return bracket > 0 ? outputPattern[..bracket].TrimEnd('.') : Path.GetFileNameWithoutExtension(outputPattern);
|
||||
}
|
||||
|
||||
internal static void PrepareOutputDir(string outputDirLocal, string outputPattern)
|
||||
{
|
||||
Directory.CreateDirectory(outputDirLocal);
|
||||
var prefix = OutputPatternPrefix(outputPattern);
|
||||
if (string.IsNullOrEmpty(prefix)) return;
|
||||
foreach (var file in Directory.EnumerateFiles(outputDirLocal))
|
||||
{
|
||||
if (Path.GetFileName(file).StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try { File.Delete(file); } catch { /* locked leftovers surface as an aerender error */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void KillTree(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited) process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch { /* already gone */ }
|
||||
}
|
||||
|
||||
private static async Task WaitForExitSafe(Process process)
|
||||
{
|
||||
try { await process.WaitForExitAsync(new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token); }
|
||||
catch { try { process.Kill(entireProcessTree: true); } catch { } }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ring buffer of the last N output lines (§5.3 logTail).</summary>
|
||||
public sealed class LogTail
|
||||
{
|
||||
private readonly Queue<string> _lines;
|
||||
private readonly int _capacity;
|
||||
|
||||
public LogTail(int capacity)
|
||||
{
|
||||
_capacity = capacity;
|
||||
_lines = new Queue<string>(capacity);
|
||||
}
|
||||
|
||||
public void Add(string line)
|
||||
{
|
||||
_lines.Enqueue(line);
|
||||
while (_lines.Count > _capacity) _lines.Dequeue();
|
||||
}
|
||||
|
||||
public override string ToString() => string.Join('\n', _lines);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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,
|
||||
// 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<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 by contract: a progress report is a nicety, and losing
|
||||
// one must never abort work in flight. Transport failures return null here
|
||||
// rather than throwing at the call site; the lease and the reaper are what
|
||||
// actually protect the job.
|
||||
public async Task<ProgressResponse?> ProgressAsync(string jobId, string machineId, double progress,
|
||||
int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw; // a real shutdown still stops us
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "Progress report failed for job {JobId} (non-fatal)", jobId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
"validation" => $"api/ext/render/jobs/{jobId}/validation",
|
||||
"delivery" => $"api/ext/render/jobs/{jobId}/delivery",
|
||||
"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 or HttpStatusCode.UnprocessableEntity)
|
||||
{
|
||||
// Conflict/NotFound: contradictory or vanished report — nothing to retry.
|
||||
// UnprocessableEntity: the server rejected the payload shape itself, so
|
||||
// resending the same bytes can only ever produce the same 422 — retrying
|
||||
// just spams the log forever instead of surfacing the real problem.
|
||||
_log.LogWarning("Server permanently rejected {Kind} for job {JobId}: {Status} {Body}",
|
||||
kind, jobId, (int)res.StatusCode, await res.Content.ReadAsStringAsync(ct));
|
||||
return true;
|
||||
}
|
||||
_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);
|
||||
|
||||
/// <summary>
|
||||
/// Reports the deployed worker version so the web UI can show what is
|
||||
/// running where. Fire-and-forget — never worth failing a startup over.
|
||||
/// </summary>
|
||||
public async Task ReportVersionAsync(WorkerOptions o, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _http.PostAsJsonAsync("api/ext/tools/ping", new
|
||||
{
|
||||
tool = "RENDER_WORKER",
|
||||
machineName = o.MachineName,
|
||||
version = o.WorkerVersion,
|
||||
hostUser = Environment.UserName,
|
||||
hostApp = $"{Environment.OSVersion.VersionString} ({Dns.GetHostName()})",
|
||||
details = new
|
||||
{
|
||||
aeVersion = o.AeVersion,
|
||||
aerenderPath = o.AerenderPath,
|
||||
ffmpeg = o.FfmpegPath is not null,
|
||||
plateSearchRoots = o.PlateSearchRoots,
|
||||
},
|
||||
}, Json, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "Version ping failed (non-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>The frame Blender is currently working on, or null.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The path of a frame Blender has just written, or null.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
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)),
|
||||
};
|
||||
|
||||
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,
|
||||
"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 { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Delivery builder (RenderPipeline2 §11). Creates the dated package folder,
|
||||
/// hard-links (or copies) each shot's EXRs and delivery MOV into it, verifies
|
||||
/// what it wrote, and leaves a manifest + CSV report beside the shots.
|
||||
///
|
||||
/// A MOV_REVIEW package (§11.6) runs the same path for a different shape: one
|
||||
/// flat folder, one preview MOV per shot, and nothing written into it but the
|
||||
/// MOVs themselves — it is shared with a producer, not filed as a delivery.
|
||||
///
|
||||
/// Hard links make an 18-shot EXR package near-instant and cost no extra disk.
|
||||
/// They are safe here because rendered versions are immutable by pipeline
|
||||
/// design — a new version is a new directory, nothing ever rewrites an EXR in
|
||||
/// place. Cross-volume targets fall back to copying automatically.
|
||||
/// </summary>
|
||||
public sealed class DeliveryStage
|
||||
{
|
||||
private readonly WorkerOptions _options;
|
||||
private readonly ApiClient _api;
|
||||
private readonly ILogger<DeliveryStage> _log;
|
||||
|
||||
public DeliveryStage(WorkerOptions options, ApiClient api, ILogger<DeliveryStage> log)
|
||||
{
|
||||
_options = options;
|
||||
_api = api;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<DeliveryResult> RunAsync(
|
||||
ClaimedJob job, DeliveryManifest manifest, string machineId, CancellationToken cancelJob, CancellationToken shutdown)
|
||||
{
|
||||
var mapper = new PathMapper(_options.PathMappings);
|
||||
var packageDir = mapper.Map(manifest.PackageDir);
|
||||
var results = new List<DeliveryItemResult>();
|
||||
var manifestFiles = new List<object>();
|
||||
var alwaysCopy = string.Equals(manifest.CopyMode, "always-copy", StringComparison.OrdinalIgnoreCase);
|
||||
var alwaysHash = string.Equals(manifest.VerifyMode, "always-hash", StringComparison.OrdinalIgnoreCase);
|
||||
var isReview = string.Equals(manifest.Kind, "MOV_REVIEW", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_log.LogInformation("Building {What} {Name} ({Count} shots) at {Dir}",
|
||||
isReview ? "MOV review" : "delivery package", manifest.PackageName, manifest.Items.Count, packageDir);
|
||||
Directory.CreateDirectory(packageDir);
|
||||
|
||||
for (var i = 0; i < manifest.Items.Count; i++)
|
||||
{
|
||||
if (cancelJob.IsCancellationRequested) break;
|
||||
var item = manifest.Items[i];
|
||||
|
||||
await _api.ProgressAsync(job.Id, machineId, (double)i / manifest.Items.Count, i, manifest.Items.Count, null,
|
||||
$"Packaging {item.ShotCode} {item.VersionString} ({i + 1}/{manifest.Items.Count})", shutdown);
|
||||
|
||||
var result = isReview
|
||||
? BuildReviewItem(item, mapper, alwaysCopy, alwaysHash, manifestFiles)
|
||||
: BuildItem(item, mapper, alwaysCopy, alwaysHash, manifestFiles);
|
||||
results.Add(result);
|
||||
if (result.Status == "FAILED")
|
||||
{
|
||||
_log.LogError("Delivery item {Shot} failed: {Error}", item.ShotCode, result.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
var ok = results.Count == manifest.Items.Count && results.All(r => r.Status == "VERIFIED");
|
||||
|
||||
// The report is the client-facing document, so it is written even for a
|
||||
// partial build — it is the fastest way to see what did and did not land.
|
||||
//
|
||||
// A review folder gets neither: it is opened by a producer who wants the
|
||||
// MOVs and nothing else. The manifest still travels back to the server,
|
||||
// so the record of what was shared is kept without cluttering the share.
|
||||
string? reportPath = null;
|
||||
if (!isReview)
|
||||
{
|
||||
try
|
||||
{
|
||||
reportPath = WriteReport(manifest, results, mapper);
|
||||
WriteManifestJson(manifest, results, manifestFiles, packageDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Could not write delivery report/manifest for {Name}", manifest.PackageName);
|
||||
}
|
||||
}
|
||||
|
||||
return new DeliveryResult(ok, results, packageDir, reportPath, manifestFiles);
|
||||
}
|
||||
|
||||
private DeliveryItemResult BuildItem(
|
||||
DeliveryManifestItem item, PathMapper mapper, bool alwaysCopy, bool alwaysHash, List<object> manifestFiles)
|
||||
{
|
||||
var sourceDir = mapper.Map(item.SourceDir);
|
||||
var shotDir = mapper.Map(item.ShotDir);
|
||||
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(sourceDir))
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, $"Source folder not found: {sourceDir}");
|
||||
}
|
||||
|
||||
var pattern = SequencePattern.FromOutputPattern(item.OutputPattern);
|
||||
var sources = Directory.EnumerateFiles(sourceDir)
|
||||
.Where(f => Path.GetFileName(f).StartsWith(pattern.Prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& Path.GetFileName(f).EndsWith(pattern.Extension, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, $"No {pattern.Extension} files matching {pattern.Prefix}* in {sourceDir}");
|
||||
}
|
||||
if (item.FileCount is int expected && sources.Count != expected)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item,
|
||||
$"Source has {sources.Count} frames but the export recorded {expected} — the render changed since QC");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(shotDir);
|
||||
|
||||
// Delivered names carry the client-facing base (or the studio's own
|
||||
// convention when no client code is set), not the internal render
|
||||
// filename — only the frame number and extension survive from the
|
||||
// source. A frame whose number can't be parsed falls back to the
|
||||
// source filename verbatim rather than failing the whole delivery.
|
||||
var destNames = sources.ToDictionary(s => s, s => DeliveredFrameName(s, item, pattern));
|
||||
|
||||
long totalBytes = 0;
|
||||
var usedCopy = false;
|
||||
foreach (var src in sources)
|
||||
{
|
||||
var dest = Path.Combine(shotDir, destNames[src]);
|
||||
if (Place(src, dest, alwaysCopy, out var copied) is string placeError)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, placeError);
|
||||
}
|
||||
usedCopy |= copied;
|
||||
totalBytes += new FileInfo(dest).Length;
|
||||
}
|
||||
|
||||
// Verification is proportional to what was actually done.
|
||||
//
|
||||
// A hard link is a second name for the same data, so hashing it
|
||||
// re-reads the exact bytes validation already hashed — it cannot
|
||||
// disagree, and at ~100 MB/s over SMB it costs half an hour for an
|
||||
// episode to prove nothing. A copy is different: it can be
|
||||
// truncated or corrupted, so it is read back in full.
|
||||
string? digest;
|
||||
string verifiedBy;
|
||||
|
||||
if (usedCopy || alwaysHash)
|
||||
{
|
||||
digest = SequenceChecksum(sources.Select(s => Path.Combine(shotDir, destNames[s])));
|
||||
if (item.Checksum is { Length: > 0 } && !string.Equals(digest, item.Checksum, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DeliveryItemResult.Failed(item,
|
||||
$"Checksum mismatch after packaging: expected {item.Checksum}, got {digest}");
|
||||
}
|
||||
verifiedBy = "hash";
|
||||
}
|
||||
else
|
||||
{
|
||||
// The one thing linking cannot rule out is the source having
|
||||
// changed since it passed QC — the link would faithfully carry
|
||||
// the new bytes. Timestamps answer that for the price of a stat.
|
||||
if (item.ValidatedAt is DateTimeOffset validatedAt)
|
||||
{
|
||||
var touched = sources
|
||||
.Select(s => new FileInfo(s))
|
||||
.Where(f => f.LastWriteTimeUtc > validatedAt.UtcDateTime.AddSeconds(1))
|
||||
.ToList();
|
||||
if (touched.Count > 0)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item,
|
||||
$"{touched.Count} source file(s) were modified after validation " +
|
||||
$"({touched[0].Name} at {touched[0].LastWriteTimeUtc:u}, validated {validatedAt.UtcDateTime:u}) — " +
|
||||
"the render changed after it passed QC");
|
||||
}
|
||||
}
|
||||
// Same data as the source, so the validation-time digest describes
|
||||
// the delivered files exactly.
|
||||
digest = item.Checksum;
|
||||
verifiedBy = "link identity";
|
||||
}
|
||||
_log.LogDebug("{Shot}: verified by {How}", item.ShotCode, verifiedBy);
|
||||
|
||||
string? movDest = null;
|
||||
if (!string.IsNullOrWhiteSpace(item.DeliveryMovPath))
|
||||
{
|
||||
var movSrc = mapper.Map(item.DeliveryMovPath!);
|
||||
if (File.Exists(movSrc))
|
||||
{
|
||||
var movName = string.IsNullOrWhiteSpace(item.DeliveredBaseName)
|
||||
? Path.GetFileName(movSrc)
|
||||
: item.DeliveredBaseName + Path.GetExtension(movSrc);
|
||||
movDest = Path.Combine(shotDir, movName);
|
||||
if (Place(movSrc, movDest, alwaysCopy, out var movCopied) is string movError)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, movError);
|
||||
}
|
||||
usedCopy |= movCopied;
|
||||
totalBytes += new FileInfo(movDest).Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not fatal: the EXRs are the deliverable, the MOV is a
|
||||
// viewing copy. It is recorded so it is not silently absent.
|
||||
_log.LogWarning("Delivery MOV missing for {Shot}: {Path}", item.ShotCode, movSrc);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var src in sources)
|
||||
{
|
||||
var name = destNames[src];
|
||||
manifestFiles.Add(new
|
||||
{
|
||||
shotCode = item.ShotCode,
|
||||
relativePath = Path.Combine(Path.GetFileName(shotDir), name).Replace('\\', '/'),
|
||||
bytes = new FileInfo(Path.Combine(shotDir, name)).Length,
|
||||
exportId = item.ExportId,
|
||||
});
|
||||
}
|
||||
|
||||
return new DeliveryItemResult
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
ShotCode = item.ShotCode,
|
||||
VersionString = item.VersionString,
|
||||
Status = "VERIFIED",
|
||||
ExrPath = shotDir,
|
||||
MovPath = movDest,
|
||||
FileCount = sources.Count,
|
||||
TotalBytes = totalBytes,
|
||||
Checksum = digest,
|
||||
LinkMode = usedCopy ? "copy" : "hardlink",
|
||||
FrameStart = item.FrameStart,
|
||||
FrameEnd = item.FrameEnd,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, $"{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One shot of a MOV review package: place its preview MOV straight into
|
||||
/// the (flat) review folder under the client-facing name.
|
||||
///
|
||||
/// A missing MOV is fatal here, unlike in an EXR delivery where the MOV is
|
||||
/// a viewing copy beside the real deliverable — in a review it *is* the
|
||||
/// deliverable, and a review folder quietly one shot short is worse than
|
||||
/// one that says so.
|
||||
/// </summary>
|
||||
private DeliveryItemResult BuildReviewItem(
|
||||
DeliveryManifestItem item, PathMapper mapper, bool alwaysCopy, bool alwaysHash, List<object> manifestFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.DeliveryMovPath))
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, "No preview MOV is recorded for this version");
|
||||
}
|
||||
|
||||
var movSrc = mapper.Map(item.DeliveryMovPath!);
|
||||
if (!File.Exists(movSrc))
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, $"Preview MOV not found: {movSrc}");
|
||||
}
|
||||
|
||||
// ShotDir is the package folder itself for a review — the MOVs sit
|
||||
// beside each other so a producer can scrub through them in order.
|
||||
var reviewDir = mapper.Map(item.ShotDir);
|
||||
Directory.CreateDirectory(reviewDir);
|
||||
|
||||
var name = string.IsNullOrWhiteSpace(item.DeliveredBaseName)
|
||||
? Path.GetFileName(movSrc)
|
||||
: item.DeliveredBaseName + Path.GetExtension(movSrc);
|
||||
var dest = Path.Combine(reviewDir, name);
|
||||
|
||||
if (Place(movSrc, dest, alwaysCopy, out var copied) is string error)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, error);
|
||||
}
|
||||
|
||||
var sourceLength = new FileInfo(movSrc).Length;
|
||||
var destLength = new FileInfo(dest).Length;
|
||||
|
||||
// There is no validation-time digest for a MOV to check against, so
|
||||
// a copy is proved against the source it came from: length always
|
||||
// (it catches the truncation a full disk causes, for free), and the
|
||||
// full hash when the config asks for it. A hard link is the source.
|
||||
string? digest = null;
|
||||
if (copied)
|
||||
{
|
||||
if (destLength != sourceLength)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item,
|
||||
$"Copied MOV is {destLength} bytes but the source is {sourceLength} — the copy is incomplete");
|
||||
}
|
||||
if (alwaysHash)
|
||||
{
|
||||
digest = XxHash64.HashFile(dest);
|
||||
var sourceDigest = XxHash64.HashFile(movSrc);
|
||||
if (!string.Equals(digest, sourceDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return DeliveryItemResult.Failed(item,
|
||||
$"Copied MOV does not match its source: expected {sourceDigest}, got {digest}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (alwaysHash)
|
||||
{
|
||||
digest = XxHash64.HashFile(dest);
|
||||
}
|
||||
|
||||
manifestFiles.Add(new
|
||||
{
|
||||
shotCode = item.ShotCode,
|
||||
relativePath = name,
|
||||
bytes = destLength,
|
||||
exportId = item.ExportId,
|
||||
});
|
||||
|
||||
return new DeliveryItemResult
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
ShotCode = item.ShotCode,
|
||||
VersionString = item.VersionString,
|
||||
Status = "VERIFIED",
|
||||
// No EXR sequence is involved, so there is no shot folder to record.
|
||||
ExrPath = null,
|
||||
MovPath = dest,
|
||||
FileCount = 1,
|
||||
TotalBytes = destLength,
|
||||
Checksum = digest,
|
||||
LinkMode = copied ? "copy" : "hardlink",
|
||||
FrameStart = item.FrameStart,
|
||||
FrameEnd = item.FrameEnd,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return DeliveryItemResult.Failed(item, $"{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hard-link when possible, copy when not. Returns null on success or the
|
||||
/// error message. `copied` reports which happened, so the package records
|
||||
/// what it actually did rather than what it intended.
|
||||
/// </summary>
|
||||
private string? Place(string source, string dest, bool alwaysCopy, out bool copied)
|
||||
{
|
||||
copied = false;
|
||||
try
|
||||
{
|
||||
if (File.Exists(dest)) File.Delete(dest);
|
||||
|
||||
if (!alwaysCopy && CreateHardLink(dest, source, IntPtr.Zero))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cross-volume, a filesystem without link support, or always-copy.
|
||||
File.Copy(source, dest, overwrite: true);
|
||||
copied = true;
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Could not place {Path.GetFileName(source)}: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The delivered filename for one source frame: DeliveredBaseName with the
|
||||
/// frame number (re-padded to the render's own convention) and extension —
|
||||
/// never the internal render filename it was copied from. Falls back to
|
||||
/// the source filename verbatim (never fatal) when there's no client-facing
|
||||
/// base to use, or the frame number can't be parsed out of the source name.
|
||||
/// </summary>
|
||||
internal string DeliveredFrameName(string src, DeliveryManifestItem item, SequencePattern pattern)
|
||||
{
|
||||
var sourceName = Path.GetFileName(src);
|
||||
if (string.IsNullOrWhiteSpace(item.DeliveredBaseName))
|
||||
{
|
||||
return sourceName;
|
||||
}
|
||||
var frame = pattern.FrameNumberOf(sourceName);
|
||||
if (frame is null)
|
||||
{
|
||||
_log.LogWarning("Could not parse a frame number out of {Name} for {Shot} — delivering under its original name",
|
||||
sourceName, item.ShotCode);
|
||||
return sourceName;
|
||||
}
|
||||
var padding = Math.Max(1, pattern.Padding);
|
||||
return $"{item.DeliveredBaseName}.{frame.Value.ToString().PadLeft(padding, '0')}{pattern.Extension}";
|
||||
}
|
||||
|
||||
private static string SequenceChecksum(IEnumerable<string> files)
|
||||
{
|
||||
var state = new XxHash64.State(0);
|
||||
foreach (var f in files)
|
||||
{
|
||||
state.Update(Encoding.ASCII.GetBytes(XxHash64.HashFile(f)));
|
||||
}
|
||||
return state.Digest().ToString("x16");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client-facing delivery report (§11.4): one row per shot. Columns are
|
||||
/// confirmed against the production's delivery spec before the first real
|
||||
/// delivery (18.2-C2), which is why this is a plain, easily-edited writer.
|
||||
/// </summary>
|
||||
private string WriteReport(DeliveryManifest manifest, List<DeliveryItemResult> results, PathMapper mapper)
|
||||
{
|
||||
var path = mapper.Map(manifest.ReportFile);
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (dir is not null) Directory.CreateDirectory(dir);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Shot,Version,FrameStart,FrameEnd,FrameCount,Files,Bytes,Checksum,MOV,Status");
|
||||
foreach (var r in results)
|
||||
{
|
||||
sb.AppendLine(string.Join(",", new[]
|
||||
{
|
||||
Csv(r.ShotCode),
|
||||
Csv(r.VersionString),
|
||||
r.FrameStart.ToString(CultureInfo.InvariantCulture),
|
||||
r.FrameEnd.ToString(CultureInfo.InvariantCulture),
|
||||
(r.FrameEnd - r.FrameStart + 1).ToString(CultureInfo.InvariantCulture),
|
||||
(r.FileCount ?? 0).ToString(CultureInfo.InvariantCulture),
|
||||
(r.TotalBytes ?? 0).ToString(CultureInfo.InvariantCulture),
|
||||
Csv(r.Checksum ?? ""),
|
||||
Csv(r.MovPath is null ? "" : Path.GetFileName(r.MovPath)),
|
||||
Csv(r.Status),
|
||||
}));
|
||||
}
|
||||
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
|
||||
_log.LogInformation("Wrote delivery report {Path}", path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private void WriteManifestJson(
|
||||
DeliveryManifest manifest, List<DeliveryItemResult> results, List<object> files, string packageDir)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
package = manifest.PackageName,
|
||||
packageNumber = manifest.PackageNumber,
|
||||
episode = manifest.Episode,
|
||||
builtAt = DateTimeOffset.UtcNow,
|
||||
builtBy = Environment.MachineName,
|
||||
checksumAlgo = manifest.ChecksumAlgo,
|
||||
shots = results.Select(r => new
|
||||
{
|
||||
r.ShotCode,
|
||||
r.VersionString,
|
||||
r.FileCount,
|
||||
r.TotalBytes,
|
||||
r.Checksum,
|
||||
r.LinkMode,
|
||||
r.Status,
|
||||
r.ErrorMessage,
|
||||
}),
|
||||
files,
|
||||
};
|
||||
var path = Path.Combine(packageDir, "manifest.json");
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(payload, WorkerOptions.JsonOpts), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static string Csv(string value) =>
|
||||
value.Contains(',') || value.Contains('"') ? "\"" + value.Replace("\"", "\"\"") + "\"" : value;
|
||||
|
||||
[DllImport("Kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes);
|
||||
}
|
||||
|
||||
public sealed record DeliveryResult(
|
||||
bool Ok, List<DeliveryItemResult> Items, string PackageDir, string? ReportPath, List<object> Files);
|
||||
|
||||
public sealed class DeliveryItemResult
|
||||
{
|
||||
public string ItemId { get; set; } = "";
|
||||
public string ShotCode { get; set; } = "";
|
||||
public string VersionString { get; set; } = "";
|
||||
public string Status { get; set; } = "PENDING";
|
||||
public string? ExrPath { get; set; }
|
||||
public string? MovPath { get; set; }
|
||||
public int? FileCount { get; set; }
|
||||
public long? TotalBytes { get; set; }
|
||||
public string? Checksum { get; set; }
|
||||
public string? LinkMode { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public int FrameStart { get; set; }
|
||||
public int FrameEnd { get; set; }
|
||||
|
||||
public static DeliveryItemResult Failed(DeliveryManifestItem item, string error) => new()
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
ShotCode = item.ShotCode,
|
||||
VersionString = item.VersionString,
|
||||
Status = "FAILED",
|
||||
ErrorMessage = error,
|
||||
FrameStart = item.FrameStart,
|
||||
FrameEnd = item.FrameEnd,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Text;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal OpenEXR header reader (RenderPipeline2 §8.1a).
|
||||
///
|
||||
/// Everything validation needs — timecode, fps, resolution, channel types,
|
||||
/// compression — lives in the header, so a check costs one 64 KB read and no
|
||||
/// pixel decode. That is why the engine needs no oiiotool/OpenImageIO
|
||||
/// dependency and can run on a bare workstation.
|
||||
///
|
||||
/// Header layout: magic(4) version(4) then attributes as
|
||||
/// `name\0 type\0 int32 size data`, terminated by an empty name.
|
||||
/// </summary>
|
||||
public sealed class ExrHeader
|
||||
{
|
||||
public const uint Magic = 20000630;
|
||||
private const int MaxHeaderBytes = 1 << 18; // 256 KB — far beyond any sane header
|
||||
|
||||
public int DataWidth { get; private init; }
|
||||
public int DataHeight { get; private init; }
|
||||
public int DisplayWidth { get; private init; }
|
||||
public int DisplayHeight { get; private init; }
|
||||
public IReadOnlyList<ExrChannel> Channels { get; private init; } = Array.Empty<ExrChannel>();
|
||||
public byte? CompressionCode { get; private init; }
|
||||
public Timecode? Timecode { get; private init; }
|
||||
public string? TimecodeString { get; private init; }
|
||||
public double? FramesPerSecond { get; private init; }
|
||||
public bool HasChromaticities { get; private init; }
|
||||
public bool IsTiled { get; private init; }
|
||||
public IReadOnlyList<string> AttributeNames { get; private init; } = Array.Empty<string>();
|
||||
|
||||
public bool HasAlpha => Channels.Any(c => c.Name.Equals("A", StringComparison.Ordinal));
|
||||
|
||||
/// <summary>half / float / uint — the deepest channel type present.</summary>
|
||||
public string? BitDepth
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Channels.Count == 0) return null;
|
||||
if (Channels.Any(c => c.PixelType == 2)) return "float";
|
||||
if (Channels.Any(c => c.PixelType == 1)) return "half";
|
||||
return "uint";
|
||||
}
|
||||
}
|
||||
|
||||
public string CompressionName => CompressionCode switch
|
||||
{
|
||||
0 => "none", 1 => "RLE", 2 => "ZIPS", 3 => "ZIP", 4 => "PIZ",
|
||||
5 => "PXR24", 6 => "B44", 7 => "B44A", 8 => "DWAA", 9 => "DWAB",
|
||||
null => "unknown",
|
||||
_ => $"code {CompressionCode}",
|
||||
};
|
||||
|
||||
/// <summary>Reads the header, or throws <see cref="InvalidDataException"/> when the file is not a readable EXR.</summary>
|
||||
public static ExrHeader Read(string path)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
var buf = new byte[Math.Min(MaxHeaderBytes, Math.Max(1024, (int)Math.Min(fs.Length, MaxHeaderBytes)))];
|
||||
var read = fs.Read(buf, 0, buf.Length);
|
||||
if (read < 16) throw new InvalidDataException("File is too small to be an EXR");
|
||||
|
||||
var span = new ReadOnlySpan<byte>(buf, 0, read);
|
||||
if (BitConverter.ToUInt32(span[..4]) != Magic) throw new InvalidDataException("Not an EXR (bad magic number)");
|
||||
var version = BitConverter.ToInt32(span.Slice(4, 4));
|
||||
|
||||
int pos = 8;
|
||||
var names = new List<string>();
|
||||
var channels = new List<ExrChannel>();
|
||||
int dataW = 0, dataH = 0, dispW = 0, dispH = 0;
|
||||
byte? compression = null;
|
||||
Timecode? tc = null;
|
||||
string? tcString = null;
|
||||
double? fps = null;
|
||||
bool chroma = false;
|
||||
|
||||
while (pos < read)
|
||||
{
|
||||
var name = ReadCString(span, ref pos);
|
||||
if (name.Length == 0) break; // end of header
|
||||
var type = ReadCString(span, ref pos);
|
||||
if (pos + 4 > read) throw new InvalidDataException("Truncated EXR header (attribute size)");
|
||||
var size = BitConverter.ToInt32(span.Slice(pos, 4));
|
||||
pos += 4;
|
||||
if (size < 0 || pos + size > read) throw new InvalidDataException($"Truncated EXR header (attribute \"{name}\")");
|
||||
var data = span.Slice(pos, size);
|
||||
names.Add(name);
|
||||
|
||||
switch (name)
|
||||
{
|
||||
case "dataWindow" when size >= 16:
|
||||
dataW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1;
|
||||
dataH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1;
|
||||
break;
|
||||
case "displayWindow" when size >= 16:
|
||||
dispW = BitConverter.ToInt32(data.Slice(8, 4)) - BitConverter.ToInt32(data[..4]) + 1;
|
||||
dispH = BitConverter.ToInt32(data.Slice(12, 4)) - BitConverter.ToInt32(data.Slice(4, 4)) + 1;
|
||||
break;
|
||||
case "compression" when size >= 1:
|
||||
compression = data[0];
|
||||
break;
|
||||
case "timeCode" when size >= 8:
|
||||
tc = VFXReviewWorker.Timecode.FromPacked(
|
||||
BitConverter.ToUInt32(data[..4]), BitConverter.ToUInt32(data.Slice(4, 4)));
|
||||
break;
|
||||
case "timeCodeString":
|
||||
tcString = Encoding.ASCII.GetString(data).TrimEnd('\0');
|
||||
break;
|
||||
case "framesPerSecond" when size >= 8:
|
||||
{
|
||||
var num = BitConverter.ToInt32(data[..4]);
|
||||
var den = BitConverter.ToUInt32(data.Slice(4, 4));
|
||||
if (den != 0) fps = num / (double)den;
|
||||
break;
|
||||
}
|
||||
case "chromaticities":
|
||||
chroma = true;
|
||||
break;
|
||||
case "channels":
|
||||
channels.AddRange(ReadChannels(data));
|
||||
break;
|
||||
}
|
||||
|
||||
pos += size;
|
||||
}
|
||||
|
||||
return new ExrHeader
|
||||
{
|
||||
DataWidth = dataW,
|
||||
DataHeight = dataH,
|
||||
DisplayWidth = dispW,
|
||||
DisplayHeight = dispH,
|
||||
Channels = channels,
|
||||
CompressionCode = compression,
|
||||
Timecode = tc,
|
||||
TimecodeString = tcString,
|
||||
FramesPerSecond = fps,
|
||||
HasChromaticities = chroma,
|
||||
IsTiled = (version & 0x200) != 0,
|
||||
AttributeNames = names,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Header read that returns null instead of throwing — for "is this file readable at all" checks.</summary>
|
||||
public static ExrHeader? TryRead(string path)
|
||||
{
|
||||
try { return Read(path); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static IEnumerable<ExrChannel> ReadChannels(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var result = new List<ExrChannel>();
|
||||
int p = 0;
|
||||
while (p < data.Length)
|
||||
{
|
||||
var name = ReadCString(data, ref p);
|
||||
if (name.Length == 0) break; // null terminator ends the channel list
|
||||
if (p + 16 > data.Length) break;
|
||||
var pixelType = BitConverter.ToInt32(data.Slice(p, 4));
|
||||
p += 16; // pixelType(4) + pLinear(1) + reserved(3) + xSampling(4) + ySampling(4)
|
||||
result.Add(new ExrChannel(name, pixelType));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ReadCString(ReadOnlySpan<byte> span, ref int pos)
|
||||
{
|
||||
int start = pos;
|
||||
while (pos < span.Length && span[pos] != 0) pos++;
|
||||
var s = Encoding.ASCII.GetString(span[start..pos]);
|
||||
pos++; // skip the terminator
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct ExrChannel(string Name, int PixelType)
|
||||
{
|
||||
public string TypeName => PixelType switch { 0 => "uint", 1 => "half", 2 => "float", _ => $"type {PixelType}" };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SMPTE 12M timecode as stored in the EXR `timeCode` attribute: BCD-packed,
|
||||
/// which is why the raw value reads back as its own hex digits
|
||||
/// (0x18585712 → 18:58:57:12).
|
||||
/// </summary>
|
||||
public readonly record struct Timecode(int Hours, int Minutes, int Seconds, int Frames, bool DropFrame)
|
||||
{
|
||||
public static Timecode FromPacked(uint timeAndFlags, uint _userData = 0)
|
||||
{
|
||||
int Bits(int shift, int mask) => (int)((timeAndFlags >> shift) & (uint)mask);
|
||||
return new Timecode(
|
||||
Bits(24, 0xF) + Bits(28, 0x3) * 10,
|
||||
Bits(16, 0xF) + Bits(20, 0x7) * 10,
|
||||
Bits(8, 0xF) + Bits(12, 0x7) * 10,
|
||||
Bits(0, 0xF) + Bits(4, 0x3) * 10,
|
||||
Bits(6, 0x1) == 1);
|
||||
}
|
||||
|
||||
public static Timecode? Parse(string? text, bool dropFrame = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
var parts = text.Trim().Split(':', ';');
|
||||
if (parts.Length < 4) return null;
|
||||
if (!int.TryParse(parts[0], out var hh) || !int.TryParse(parts[1], out var mm) ||
|
||||
!int.TryParse(parts[2], out var ss) || !int.TryParse(parts[3], out var ff)) return null;
|
||||
return new Timecode(hh, mm, ss, ff, dropFrame || text.Contains(';'));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-drop frame count. The studio shoots 24 fps, so drop-frame never
|
||||
/// applies; a drop-frame flag mismatch is reported as its own check rather
|
||||
/// than silently changing this arithmetic.
|
||||
/// </summary>
|
||||
public long ToFrames(double fps)
|
||||
{
|
||||
var rate = (long)Math.Round(fps <= 0 ? 24 : fps);
|
||||
return ((Hours * 60L + Minutes) * 60L + Seconds) * rate + Frames;
|
||||
}
|
||||
|
||||
public static Timecode FromFrames(long frames, double fps)
|
||||
{
|
||||
var rate = (long)Math.Round(fps <= 0 ? 24 : fps);
|
||||
if (rate <= 0) rate = 24;
|
||||
var f = Math.Max(0, frames);
|
||||
var ff = (int)(f % rate);
|
||||
var totalSeconds = f / rate;
|
||||
return new Timecode((int)(totalSeconds / 3600), (int)(totalSeconds / 60 % 60), (int)(totalSeconds % 60), ff, false);
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"{Hours:D2}:{Minutes:D2}:{Seconds:D2}{(DropFrame ? ';' : ':')}{Frames:D2}";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal rolling file logger (§7.9): one file per day under
|
||||
/// %ProgramData%\VFXReviewWorker\logs, files older than 14 days pruned on
|
||||
/// startup and at midnight rollover. No external logging dependencies.
|
||||
/// </summary>
|
||||
public sealed class FileLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private StreamWriter? _writer;
|
||||
private DateOnly _currentDay;
|
||||
|
||||
public FileLoggerProvider()
|
||||
{
|
||||
Directory.CreateDirectory(WorkerOptions.LogsDir);
|
||||
Prune();
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName);
|
||||
|
||||
internal void Write(string category, LogLevel level, string message, Exception? ex)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
if (_writer is null || today != _currentDay)
|
||||
{
|
||||
_writer?.Dispose();
|
||||
_currentDay = today;
|
||||
_writer = new StreamWriter(
|
||||
Path.Combine(WorkerOptions.LogsDir, $"worker_{today:yyyyMMdd}.log"),
|
||||
append: true) { AutoFlush = true };
|
||||
Prune();
|
||||
}
|
||||
_writer.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{level,-5}] {Shorten(category)}: {message}{(ex is null ? "" : $"\n{ex}")}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Shorten(string category) => category[(category.LastIndexOf('.') + 1)..];
|
||||
|
||||
private static void Prune()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-14);
|
||||
foreach (var f in Directory.EnumerateFiles(WorkerOptions.LogsDir, "*.log"))
|
||||
{
|
||||
if (File.GetLastWriteTime(f) < cutoff) File.Delete(f);
|
||||
}
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock) { _writer?.Dispose(); _writer = null; }
|
||||
}
|
||||
|
||||
private sealed class FileLogger : ILogger
|
||||
{
|
||||
private readonly FileLoggerProvider _provider;
|
||||
private readonly string _category;
|
||||
|
||||
public FileLogger(FileLoggerProvider provider, string category)
|
||||
{
|
||||
_provider = provider;
|
||||
_category = category;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel)) return;
|
||||
_provider.Write(_category, logLevel, formatter(state, exception), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Executes one claimed job end-to-end: writes crash-recovery state, runs
|
||||
/// aerender, uploads the full log, and enqueues the durable complete/fail
|
||||
/// report. One job at a time per machine (§7.2).
|
||||
/// </summary>
|
||||
public sealed class JobExecutor
|
||||
{
|
||||
private readonly WorkerOptions _options;
|
||||
private readonly ApiClient _api;
|
||||
private readonly AerenderRunner _runner;
|
||||
private readonly PreviewStage _preview;
|
||||
private readonly ValidationStage _validation;
|
||||
private readonly DeliveryStage _delivery;
|
||||
private readonly BlenderRunner _blender;
|
||||
private readonly ReportSpooler _spooler;
|
||||
private readonly ILogger<JobExecutor> _log;
|
||||
|
||||
public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview,
|
||||
ValidationStage validation, DeliveryStage delivery, BlenderRunner blender, ReportSpooler spooler, ILogger<JobExecutor> log)
|
||||
{
|
||||
_options = options;
|
||||
_api = api;
|
||||
_runner = runner;
|
||||
_preview = preview;
|
||||
_validation = validation;
|
||||
_delivery = delivery;
|
||||
_blender = blender;
|
||||
_spooler = spooler;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task RunAsync(ClaimedJob job, string machineId, ServerConfig config, CancellationToken cancelJob, CancellationToken shutdown)
|
||||
{
|
||||
if (job.Type == "PREVIEW_ONLY")
|
||||
{
|
||||
await RunPreviewAsync(job, machineId, config, cancelJob, shutdown);
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.Type == "DELIVERY_BUILD")
|
||||
{
|
||||
await RunDeliveryAsync(job, machineId, cancelJob, shutdown);
|
||||
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);
|
||||
|
||||
WriteState(new CurrentJobState
|
||||
{
|
||||
JobId = job.Id,
|
||||
ExportId = job.ExportId,
|
||||
MachineId = machineId,
|
||||
OutputDirLocal = outputDirLocal,
|
||||
ClaimedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _runner.RunAsync(job, manifest, machineId, outputDirLocal, config, cancelJob, shutdown);
|
||||
var logKey = await _api.UploadLogAsync(job.Id, machineId, result.FullLogPath, shutdown);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
long totalBytes = 0;
|
||||
int fileCount = 0;
|
||||
var prefix = AerenderRunner.OutputPatternPrefix(manifest.OutputPattern);
|
||||
try
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(outputDirLocal))
|
||||
{
|
||||
if (!Path.GetFileName(f).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
fileCount++;
|
||||
totalBytes += new FileInfo(f).Length;
|
||||
}
|
||||
}
|
||||
catch { /* stats are best-effort in Phase 2; validation owns this in Phase 3 */ }
|
||||
|
||||
if (fileCount == 0)
|
||||
{
|
||||
// aerender can exit 0 having rendered nothing (bad frame
|
||||
// range, template mismatch, silently swallowed error).
|
||||
// Until Phase 3 validation exists, an empty output dir must
|
||||
// never become READY_FOR_QC.
|
||||
_log.LogError("Job {JobId}: aerender exited 0 but no \"{Prefix}*\" files exist in {Dir} — reporting failure",
|
||||
job.Id, prefix, outputDirLocal);
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "RENDER",
|
||||
["exitCode"] = 0,
|
||||
["errorMessage"] = "aerender exited 0 but produced no output files in " + outputDirLocal,
|
||||
["logTail"] = result.LogTail,
|
||||
["logFileKey"] = logKey,
|
||||
["retryable"] = false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_log.LogInformation("Job {JobId} complete: {Frames} frames in {Sec:F0}s ({Files} files, {Bytes} bytes)",
|
||||
job.Id, manifest.TotalFrames, result.RenderSeconds, fileCount, totalBytes);
|
||||
_spooler.Enqueue("complete-render", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["renderSeconds"] = (int)result.RenderSeconds,
|
||||
["logFileKey"] = logKey,
|
||||
["logTail"] = result.LogTail,
|
||||
["exrFileCount"] = fileCount,
|
||||
["exrTotalBytes"] = totalBytes,
|
||||
});
|
||||
|
||||
// Validation runs here, while we still hold the job and the
|
||||
// files are local. It is queued behind complete-render, and the
|
||||
// spooler's in-order replay is what guarantees the server has
|
||||
// moved the Export to VALIDATING before this lands (§8).
|
||||
if (config.ValidationEnabled)
|
||||
{
|
||||
await RunValidationAsync(job, manifest, machineId, outputDirLocal, config, shutdown);
|
||||
}
|
||||
}
|
||||
else if (result.ErrorMessage == "Cancelled")
|
||||
{
|
||||
// Ack the cancel so the server clears the CANCEL_JOB command
|
||||
_log.LogInformation("Job {JobId} cancelled — acknowledging", job.Id);
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "RENDER",
|
||||
["errorMessage"] = "Cancelled by server",
|
||||
["logTail"] = result.LogTail,
|
||||
["logFileKey"] = logKey,
|
||||
["retryable"] = false,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.LogError("Job {JobId} failed (exit {Exit}, retryable {Retryable}): {Error}",
|
||||
job.Id, result.ExitCode, result.Retryable, result.ErrorMessage);
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "RENDER",
|
||||
["exitCode"] = result.ExitCode,
|
||||
["errorMessage"] = result.ErrorMessage,
|
||||
["logTail"] = result.LogTail,
|
||||
["logFileKey"] = logKey,
|
||||
["retryable"] = result.Retryable,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Unexpected executor error for job {JobId}", job.Id);
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "RENDER",
|
||||
["errorMessage"] = $"Worker exception: {ex.Message}",
|
||||
["retryable"] = true,
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["renderSeconds"] = (int)result.RenderSeconds,
|
||||
["logFileKey"] = logKey,
|
||||
["logTail"] = result.LogTail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "BLENDER",
|
||||
["errorMessage"] = $"Worker exception: {ex.Message}",
|
||||
["retryable"] = true,
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELIVERY_BUILD job: build the dated package folder from already-QC'd
|
||||
/// exports. No AE involved — folders, links and verification only.
|
||||
/// </summary>
|
||||
private async Task RunDeliveryAsync(ClaimedJob job, string machineId, CancellationToken cancelJob, CancellationToken shutdown)
|
||||
{
|
||||
var manifest = job.AsDeliveryManifest();
|
||||
|
||||
WriteState(new CurrentJobState
|
||||
{
|
||||
JobId = job.Id,
|
||||
ExportId = null,
|
||||
MachineId = machineId,
|
||||
OutputDirLocal = new PathMapper(_options.PathMappings).Map(manifest.PackageDir),
|
||||
ClaimedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _delivery.RunAsync(job, manifest, machineId, cancelJob, shutdown);
|
||||
|
||||
_log.LogInformation("Delivery {Name}: {Ok} ({Verified}/{Total} shots verified)",
|
||||
manifest.PackageName, result.Ok ? "READY" : "FAILED",
|
||||
result.Items.Count(i => i.Status == "VERIFIED"), manifest.Items.Count);
|
||||
|
||||
string? reportKey = null;
|
||||
if (result.ReportPath is not null)
|
||||
{
|
||||
reportKey = await _api.UploadArtifactAsync(job.Id, machineId, "report", result.ReportPath, "text/csv", shutdown);
|
||||
}
|
||||
|
||||
_spooler.Enqueue("delivery", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["ok"] = result.Ok,
|
||||
["rootPath"] = result.PackageDir,
|
||||
["reportKey"] = reportKey,
|
||||
["items"] = result.Items.Select(i => new Dictionary<string, object?>
|
||||
{
|
||||
["itemId"] = i.ItemId,
|
||||
["status"] = i.Status,
|
||||
["exrPath"] = i.ExrPath,
|
||||
["movPath"] = i.MovPath,
|
||||
["fileCount"] = i.FileCount,
|
||||
["totalBytes"] = i.TotalBytes,
|
||||
["checksum"] = i.Checksum,
|
||||
["linkMode"] = i.LinkMode,
|
||||
["errorMessage"] = i.ErrorMessage,
|
||||
}).ToList(),
|
||||
["manifest"] = new Dictionary<string, object?>
|
||||
{
|
||||
["packageDir"] = result.PackageDir,
|
||||
["reportPath"] = result.ReportPath,
|
||||
["files"] = result.Files,
|
||||
},
|
||||
["errorMessage"] = result.Ok
|
||||
? null
|
||||
: string.Join("; ", result.Items.Where(i => i.Status == "FAILED").Select(i => $"{i.ShotCode}: {i.ErrorMessage}").Take(5)),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Unexpected delivery error for job {JobId}", job.Id);
|
||||
_spooler.Enqueue("delivery", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["ok"] = false,
|
||||
["items"] = new List<object>(),
|
||||
["errorMessage"] = $"Worker exception: {ex.Message}",
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the validation engine and spools the E12 report. Deliberately
|
||||
/// non-fatal on its own errors: a crash in the checks must not lose a
|
||||
/// finished render, so it is reported as a failed validation run rather
|
||||
/// than being allowed to escape.
|
||||
/// </summary>
|
||||
private async Task RunValidationAsync(
|
||||
ClaimedJob job, RenderManifest manifest, string machineId, string outputDirLocal,
|
||||
ServerConfig config, CancellationToken shutdown)
|
||||
{
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
try
|
||||
{
|
||||
// Keep the lease alive: the job stays open through validation.
|
||||
await _api.ProgressAsync(job.Id, machineId, 1.0, manifest.FrameEnd, manifest.TotalFrames, 0,
|
||||
"Validating rendered sequence…", shutdown);
|
||||
|
||||
var outcome = await Task.Run(() => _validation.Run(manifest, outputDirLocal, config), shutdown);
|
||||
var seconds = (DateTimeOffset.UtcNow - started).TotalSeconds;
|
||||
|
||||
var failed = outcome.Checks.Where(c => c.Status == "FAIL").Select(c => c.CheckName).ToList();
|
||||
if (failed.Count > 0)
|
||||
{
|
||||
_log.LogWarning("Job {JobId} validation FAILED in {Sec:F1}s: {Checks}", job.Id, seconds, string.Join(", ", failed));
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.LogInformation("Job {JobId} validation passed in {Sec:F1}s ({Count} checks, {Files} files)",
|
||||
job.Id, seconds, outcome.Checks.Count, outcome.FileCount);
|
||||
}
|
||||
|
||||
_spooler.Enqueue("validation", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["checks"] = outcome.Checks,
|
||||
["checksum"] = outcome.Checksum,
|
||||
["checksumAlgo"] = "xxh64",
|
||||
["exrFileCount"] = outcome.FileCount,
|
||||
["exrTotalBytes"] = outcome.TotalBytes,
|
||||
["validationSeconds"] = (int)seconds,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Validation engine error for job {JobId}", job.Id);
|
||||
_spooler.Enqueue("validation", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["checks"] = new[]
|
||||
{
|
||||
ValidationCheck.Fail("engine_error", "validation completes", "worker exception",
|
||||
$"The validation engine threw: {ex.Message}"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate
|
||||
/// and burn-ins, render the delivery MOV + review MP4, upload the review
|
||||
/// media, and finalize (which registers the internal-only Version).
|
||||
/// </summary>
|
||||
private async Task RunPreviewAsync(ClaimedJob job, string machineId, ServerConfig config, CancellationToken cancelJob, CancellationToken shutdown)
|
||||
{
|
||||
var manifest = job.AsPreviewManifest();
|
||||
|
||||
WriteState(new CurrentJobState
|
||||
{
|
||||
JobId = job.Id,
|
||||
ExportId = job.ExportId,
|
||||
MachineId = machineId,
|
||||
OutputDirLocal = new PathMapper(_options.PathMappings).Map(manifest.OutputDir),
|
||||
ClaimedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _preview.RunAsync(job, manifest, machineId, config, cancelJob, shutdown);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_log.LogError("Preview job {JobId} failed (retryable {Retryable}): {Error}",
|
||||
job.Id, result.Retryable, result.ErrorMessage);
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "PREVIEW",
|
||||
["errorMessage"] = result.ErrorMessage,
|
||||
["retryable"] = result.Retryable,
|
||||
});
|
||||
return; // temp AEP is deliberately kept for debugging
|
||||
}
|
||||
|
||||
var previewKey = await _api.UploadArtifactAsync(
|
||||
job.Id, machineId, "preview", result.Mp4Path!, "video/mp4", shutdown);
|
||||
if (previewKey is null)
|
||||
{
|
||||
// The renders are on the SAN and fine — only the upload failed,
|
||||
// so this is worth retrying without rebuilding anything.
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "PREVIEW",
|
||||
["errorMessage"] = "Preview MP4 upload failed",
|
||||
["retryable"] = true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
string? thumbKey = null;
|
||||
if (result.ThumbnailPath is not null)
|
||||
{
|
||||
thumbKey = await _api.UploadArtifactAsync(
|
||||
job.Id, machineId, "thumbnail", result.ThumbnailPath, "image/jpeg", shutdown);
|
||||
}
|
||||
|
||||
_spooler.Enqueue("finalize", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["artifacts"] = new Dictionary<string, object?>
|
||||
{
|
||||
["deliveryMovPath"] = manifest.MovOutput,
|
||||
["previewMovKey"] = previewKey,
|
||||
["thumbnailKey"] = thumbKey,
|
||||
},
|
||||
["renderStats"] = new Dictionary<string, object?>
|
||||
{
|
||||
["previewSeconds"] = (int)result.Seconds,
|
||||
},
|
||||
["media"] = new Dictionary<string, object?>
|
||||
{
|
||||
["width"] = result.Build?.Width,
|
||||
["height"] = result.Build?.Height,
|
||||
["frameCount"] = result.Build?.FrameCount,
|
||||
["fps"] = manifest.Fps,
|
||||
["fileName"] = Path.GetFileName(result.Mp4Path!),
|
||||
},
|
||||
});
|
||||
|
||||
// Only clean up once the outputs are safely reported.
|
||||
CleanScratch(job.Id, result.TempAep);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Unexpected preview error for job {JobId}", job.Id);
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "PREVIEW",
|
||||
["errorMessage"] = $"Worker exception: {ex.Message}",
|
||||
["retryable"] = true,
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearState();
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanScratch(string jobId, string? tempAep)
|
||||
{
|
||||
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_context.json"));
|
||||
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_result.json"));
|
||||
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_run.jsx"));
|
||||
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg"));
|
||||
if (tempAep is not null) PreviewStage.TryDelete(tempAep);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crash recovery (§7.5): on service start, if current-job.json names a
|
||||
/// job, ask the server what became of it. Still ours (CLAIMED/RUNNING) →
|
||||
/// we crashed mid-render; report a retryable fail so the server requeues
|
||||
/// (never resume a partial aerender). Reassigned/finished → discard state;
|
||||
/// the next attempt's PrepareOutputDir clears any orphaned frames.
|
||||
/// </summary>
|
||||
public async Task ReconcileAsync(string machineId, CancellationToken ct)
|
||||
{
|
||||
var state = ReadState();
|
||||
if (state is null) return;
|
||||
_log.LogWarning("Found crash-recovery state for job {JobId} — reconciling", state.JobId);
|
||||
try
|
||||
{
|
||||
var stillOurs = false;
|
||||
if (!string.IsNullOrEmpty(state.ExportId))
|
||||
{
|
||||
var export = await _api.GetExportAsync(state.ExportId!, ct);
|
||||
var job = export?.RenderJobs.FirstOrDefault(j => j.Id == state.JobId);
|
||||
stillOurs = job?.Status is "CLAIMED" or "RUNNING";
|
||||
}
|
||||
if (stillOurs)
|
||||
{
|
||||
_log.LogWarning("Server still shows job {JobId} as ours — reporting crash for requeue", state.JobId);
|
||||
_spooler.Enqueue("fail", state.JobId, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
["stage"] = "RENDER",
|
||||
["errorMessage"] = "Worker restarted mid-render (crash/power loss); never resuming a partial render",
|
||||
["retryable"] = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Reconcile failed for job {JobId}; discarding local state (reaper covers the lease)", state.JobId);
|
||||
}
|
||||
ClearState();
|
||||
}
|
||||
|
||||
private static void WriteState(CurrentJobState state)
|
||||
{
|
||||
Directory.CreateDirectory(WorkerOptions.DataDir);
|
||||
File.WriteAllText(WorkerOptions.StateFilePath, JsonSerializer.Serialize(state, WorkerOptions.JsonOpts));
|
||||
}
|
||||
|
||||
private static CurrentJobState? ReadState()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(WorkerOptions.StateFilePath)) return null;
|
||||
return JsonSerializer.Deserialize<CurrentJobState>(File.ReadAllText(WorkerOptions.StateFilePath), WorkerOptions.JsonOpts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearState()
|
||||
{
|
||||
try { File.Delete(WorkerOptions.StateFilePath); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Translates canonical UNC paths from manifests to this machine's local
|
||||
/// drive mappings (§7.8 pathMappings). Comparison is case-insensitive and
|
||||
/// slash-direction tolerant; the first matching mapping wins.
|
||||
/// </summary>
|
||||
public sealed class PathMapper
|
||||
{
|
||||
private readonly List<(string From, string To)> _mappings;
|
||||
|
||||
public PathMapper(IEnumerable<PathMapping> mappings)
|
||||
{
|
||||
_mappings = mappings
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m.From))
|
||||
.Select(m => (Normalize(m.From), m.To))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string Normalize(string p) => p.Replace('\\', '/');
|
||||
|
||||
public string Map(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
var normalized = Normalize(path);
|
||||
foreach (var (from, to) in _mappings)
|
||||
{
|
||||
if (normalized.StartsWith(from, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var mapped = to + normalized[from.Length..];
|
||||
return mapped.Replace('/', Path.DirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
return path.Replace('/', Path.DirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// `VFXReviewWorker.exe --check` — install verification for a new machine.
|
||||
/// Resolves every path and dependency the worker needs, reports what is
|
||||
/// missing, and exits non-zero if anything would stop it running. Cheaper than
|
||||
/// installing the service and reading logs to find a typo'd path.
|
||||
/// </summary>
|
||||
public static class Preflight
|
||||
{
|
||||
public static async Task<int> RunAsync(string? configPath)
|
||||
{
|
||||
var ok = true;
|
||||
Console.WriteLine("VFXReview RenderWorker preflight");
|
||||
Console.WriteLine();
|
||||
|
||||
var resolvedConfigPath = configPath ?? WorkerOptions.DefaultConfigPath;
|
||||
WorkerOptions? options = null;
|
||||
try
|
||||
{
|
||||
options = WorkerOptions.Load(configPath);
|
||||
ok &= Report("config file", resolvedConfigPath, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Report("config file", resolvedConfigPath, false, ex.Message);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Cannot continue without a config file. See RenderWorker/README.md.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($" worker version : {options.WorkerVersion}");
|
||||
Console.WriteLine($" machine name : {options.MachineName}");
|
||||
Console.WriteLine($" server : {options.ServerUrl}");
|
||||
Console.WriteLine($" api key : {(string.IsNullOrWhiteSpace(options.ApiKey) ? "(missing)" : "set")}");
|
||||
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");
|
||||
var script = options.ResolvePreviewScriptPath();
|
||||
ok &= Report("preview script", script, File.Exists(script),
|
||||
"needed for preview builds only");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.FfmpegPath))
|
||||
{
|
||||
Report("ffmpeg", options.FfmpegPath!, File.Exists(options.FfmpegPath!),
|
||||
"optional — thumbnails are skipped without it");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" [ - ] ffmpeg not configured (thumbnails will be skipped)");
|
||||
}
|
||||
|
||||
// Path mappings: prove the mapped roots are actually reachable here.
|
||||
Console.WriteLine();
|
||||
if (options.PathMappings.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" [ ! ] no pathMappings configured — manifest UNC paths will be used as-is");
|
||||
}
|
||||
foreach (var m in options.PathMappings)
|
||||
{
|
||||
var reachable = Directory.Exists(m.To);
|
||||
Report($"path map {m.From}", m.To, reachable, "target must be reachable from this machine");
|
||||
ok &= reachable;
|
||||
}
|
||||
|
||||
// Server reachability + API key validity in one call.
|
||||
Console.WriteLine();
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { BaseAddress = new Uri(options.ServerUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(15) };
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
|
||||
var res = await http.GetAsync("api/ext/projects");
|
||||
if (res.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine(" [ok ] server reachable and API key accepted");
|
||||
}
|
||||
else if ((int)res.StatusCode == 401)
|
||||
{
|
||||
Console.WriteLine(" [FAIL] server reachable but API key was rejected (401)");
|
||||
ok = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($" [FAIL] server responded {(int)res.StatusCode}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" [FAIL] cannot reach {options.ServerUrl}: {ex.Message}");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(ok
|
||||
? "All checks passed — safe to install the service."
|
||||
: "One or more checks FAILED — fix the above before installing.");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
private static bool Report(string label, string path, bool exists, string? note = null)
|
||||
{
|
||||
var status = exists ? "ok " : "FAIL";
|
||||
Console.WriteLine($" [{status}] {label,-16} {path}");
|
||||
if (!exists && note is not null)
|
||||
{
|
||||
Console.WriteLine($" ({note})");
|
||||
}
|
||||
return exists;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
public sealed record PreviewStageResult(
|
||||
bool Success,
|
||||
bool Retryable,
|
||||
string? ErrorMessage,
|
||||
string? MovPath,
|
||||
string? Mp4Path,
|
||||
string? ThumbnailPath,
|
||||
string? TempAep,
|
||||
PreviewBuildResult? Build,
|
||||
double Seconds);
|
||||
|
||||
/// <summary>
|
||||
/// Stage 3 — preview generation (RenderPipeline2 §9), the second half of a
|
||||
/// one-click export.
|
||||
///
|
||||
/// Runs in two steps so the unproven part stays small:
|
||||
/// 1. AfterFX.com -noui -r → builds the shot around the rendered EXRs using
|
||||
/// the studio slate/overlay template, queues the MOV + MP4 output modules
|
||||
/// and saves a throwaway AEP. Does no rendering.
|
||||
/// 2. aerender -project <that aep> (no -comp) → renders the whole queue in
|
||||
/// one launch, through the same proven runner as the EXR stage.
|
||||
/// </summary>
|
||||
public sealed class PreviewStage
|
||||
{
|
||||
private readonly WorkerOptions _options;
|
||||
private readonly AerenderRunner _runner;
|
||||
private readonly ILogger<PreviewStage> _log;
|
||||
|
||||
public PreviewStage(WorkerOptions options, AerenderRunner runner, ILogger<PreviewStage> log)
|
||||
{
|
||||
_options = options;
|
||||
_runner = runner;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<PreviewStageResult> RunAsync(
|
||||
ClaimedJob job,
|
||||
PreviewManifest manifest,
|
||||
string machineId,
|
||||
ServerConfig config,
|
||||
CancellationToken cancelJob,
|
||||
CancellationToken shutdown)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var mapper = new PathMapper(_options.PathMappings);
|
||||
Directory.CreateDirectory(WorkerOptions.ScratchDir);
|
||||
|
||||
var contextPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_context.json");
|
||||
var resultPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_result.json");
|
||||
var wrapperPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_run.jsx");
|
||||
var tempAep = Path.Combine(WorkerOptions.ScratchDir, $"job_{job.Id}_preview.aep");
|
||||
|
||||
var movLocal = mapper.Map(manifest.MovOutput);
|
||||
var mp4Local = mapper.Map(manifest.Mp4Output);
|
||||
|
||||
// Everything the script needs, in machine-local paths.
|
||||
var context = new
|
||||
{
|
||||
jobId = job.Id,
|
||||
exportId = manifest.ExportId,
|
||||
shotCode = manifest.ShotCode,
|
||||
versionString = manifest.VersionString,
|
||||
outputDirLocal = mapper.Map(manifest.OutputDir),
|
||||
templateAep = mapper.Map(manifest.TemplateAep),
|
||||
templateComp = manifest.TemplateComp,
|
||||
overlayComp = manifest.OverlayComp,
|
||||
lutComp = manifest.LutComp,
|
||||
movTemplate = manifest.MovTemplate,
|
||||
mp4Template = manifest.Mp4Template,
|
||||
movOutput = movLocal,
|
||||
mp4Output = mp4Local,
|
||||
tempAep,
|
||||
resultPath,
|
||||
frameStart = manifest.FrameStart,
|
||||
fps = manifest.Fps,
|
||||
slateScopeProp = manifest.SlateScopeProp,
|
||||
slateSubmissionProp = manifest.SlateSubmissionProp,
|
||||
slate = manifest.Slate,
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(contextPath, JsonSerializer.Serialize(context, WorkerOptions.JsonOpts), shutdown);
|
||||
|
||||
var scriptPath = _options.ResolvePreviewScriptPath();
|
||||
if (!File.Exists(scriptPath))
|
||||
{
|
||||
return Fail($"Preview build script not found at {scriptPath}", retryable: false, sw);
|
||||
}
|
||||
|
||||
// ExtendScript cannot read argv — hand the context path over through a
|
||||
// generated wrapper that evaluates the shared build script.
|
||||
var wrapper = new StringBuilder()
|
||||
.AppendLine("// generated by VFXReviewWorker — do not edit")
|
||||
.AppendLine($"var VFXR_CONTEXT_PATH = {JsonSerializer.Serialize(contextPath)};")
|
||||
.AppendLine($"$.evalFile({JsonSerializer.Serialize(scriptPath)});")
|
||||
.ToString();
|
||||
await File.WriteAllTextAsync(wrapperPath, wrapper, shutdown);
|
||||
|
||||
TryDelete(resultPath);
|
||||
|
||||
// ── Step 1: headless build ───────────────────────────────────────────
|
||||
var afterFx = _options.ResolveAfterFxPath();
|
||||
if (!File.Exists(afterFx))
|
||||
{
|
||||
return Fail($"AfterFX.com not found at {afterFx}", retryable: false, sw);
|
||||
}
|
||||
|
||||
// Belt and braces: the claim loop already avoids preview jobs while AE
|
||||
// is open, but an artist may have launched it in between. Never take
|
||||
// over a live session — defer instead (retryable).
|
||||
if (AeSession.InteractiveRunning())
|
||||
{
|
||||
return Fail(
|
||||
"An interactive After Effects session is open on this machine — preview build deferred to avoid taking it over",
|
||||
retryable: true, sw);
|
||||
}
|
||||
|
||||
_log.LogInformation("Building preview comp for job {JobId} via {AfterFx}", job.Id, afterFx);
|
||||
var buildExit = await RunAfterFxAsync(afterFx, wrapperPath, config, cancelJob, shutdown);
|
||||
|
||||
if (!File.Exists(resultPath))
|
||||
{
|
||||
return Fail(
|
||||
$"Headless AE produced no result file (exit {buildExit}). Check that AfterFX.com can run under this account.",
|
||||
retryable: true, sw);
|
||||
}
|
||||
|
||||
PreviewBuildResult? build;
|
||||
try
|
||||
{
|
||||
build = JsonSerializer.Deserialize<PreviewBuildResult>(
|
||||
await File.ReadAllTextAsync(resultPath, shutdown), WorkerOptions.JsonOpts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Fail($"Could not parse preview build result: {ex.Message}", retryable: false, sw);
|
||||
}
|
||||
|
||||
if (build is null || !build.Ok)
|
||||
{
|
||||
// A build failure is deterministic (missing template comp, missing
|
||||
// output module, bad layer name) — humans fix the template.
|
||||
return Fail($"Preview build failed: {build?.Error ?? "unknown error"}", retryable: false, sw, build);
|
||||
}
|
||||
foreach (var w in build.Warnings)
|
||||
{
|
||||
_log.LogWarning("Preview build warning (job {JobId}): {Warning}", job.Id, w);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(build.TempAep) || !File.Exists(build.TempAep))
|
||||
{
|
||||
return Fail("Preview build reported success but the temp project is missing", retryable: false, sw, build);
|
||||
}
|
||||
|
||||
// ── Step 2: render the prepared queue ────────────────────────────────
|
||||
var render = await _runner.RunProjectAsync(
|
||||
job.Id, machineId, build.TempAep, build.FrameCount ?? manifest.TotalFrames,
|
||||
config, cancelJob, shutdown);
|
||||
|
||||
if (!render.Success)
|
||||
{
|
||||
return new PreviewStageResult(false, render.Retryable, render.ErrorMessage,
|
||||
null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds);
|
||||
}
|
||||
|
||||
// aerender can exit 0 without writing — verify both outputs exist.
|
||||
var missing = new List<string>();
|
||||
if (!File.Exists(movLocal)) missing.Add(movLocal);
|
||||
if (!File.Exists(mp4Local)) missing.Add(mp4Local);
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
return new PreviewStageResult(false, false,
|
||||
"aerender exited 0 but expected preview outputs are missing: " + string.Join(", ", missing),
|
||||
null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds);
|
||||
}
|
||||
|
||||
var thumbnail = await TryMakeThumbnailAsync(job.Id, mp4Local, build, manifest.Fps, shutdown);
|
||||
|
||||
sw.Stop();
|
||||
_log.LogInformation("Preview complete for job {JobId} in {Sec:F0}s (mov + mp4{Thumb})",
|
||||
job.Id, sw.Elapsed.TotalSeconds, thumbnail is null ? "" : " + thumbnail");
|
||||
|
||||
return new PreviewStageResult(true, true, null, movLocal, mp4Local, thumbnail,
|
||||
build.TempAep, build, sw.Elapsed.TotalSeconds);
|
||||
}
|
||||
|
||||
private async Task<int> RunAfterFxAsync(
|
||||
string afterFx, string wrapperPath, ServerConfig config,
|
||||
CancellationToken cancelJob, CancellationToken shutdown)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = afterFx,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
psi.ArgumentList.Add("-noui");
|
||||
psi.ArgumentList.Add("-r");
|
||||
psi.ArgumentList.Add(wrapperPath);
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) _log.LogDebug("[AfterFX] {Line}", e.Data); };
|
||||
process.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) _log.LogDebug("[AfterFX] {Line}", e.Data); };
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
// The build does no rendering, so it should finish in well under the
|
||||
// stall timeout. A hang here means AE is waiting on something (a dialog,
|
||||
// a licence prompt) and must be killed rather than left holding the job.
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancelJob, shutdown);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(120, config.StallTimeoutSeconds)));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_log.LogError("Headless AE build timed out or was cancelled — killing process tree");
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
return -1;
|
||||
}
|
||||
return process.ExitCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional 960-wide poster frame at 25% of <em>picture</em> duration
|
||||
/// (§9.1). Never fatal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The preview MP4 opens on the Netflix slate, so the frame has to be taken
|
||||
/// relative to picture rather than to the file. This previously used
|
||||
/// ffmpeg's <c>thumbnail</c> filter, which considers only the first 100
|
||||
/// frames — on a slated preview that is the slate card, which is what every
|
||||
/// thumbnail in the system was until now. Seeking to
|
||||
/// <c>pictureStart + 25%</c> matches what §9.1 always documented and is what
|
||||
/// makes these frames usable as shot thumbnails.
|
||||
///
|
||||
/// A build that reports no picture start (older worker, or a template with
|
||||
/// no SHOT layer) keeps the old whole-file behaviour rather than guessing.
|
||||
/// </remarks>
|
||||
private async Task<string?> TryMakeThumbnailAsync(
|
||||
string jobId, string mp4Path, PreviewBuildResult? build, double fps, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.FfmpegPath))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"No thumbnail for job {JobId}: ffmpegPath is not set in config.json. " +
|
||||
"Thumbnails are skipped, and shots cannot take a frame from this render.",
|
||||
jobId);
|
||||
return null;
|
||||
}
|
||||
if (!File.Exists(_options.FfmpegPath))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"No thumbnail for job {JobId}: ffmpegPath points at {Path}, which does not exist.",
|
||||
jobId, _options.FfmpegPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var outPath = Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg");
|
||||
var seek = ThumbnailSeekSeconds(build, fps);
|
||||
|
||||
if (seek is { } t)
|
||||
{
|
||||
_log.LogInformation(
|
||||
"Thumbnail for job {JobId}: seeking to {Seek:F2}s (slate {Start:F2}s + 25% of {Frames} frames @ {Fps}fps)",
|
||||
jobId, t, build!.PictureStartSeconds, build.FrameCount, fps);
|
||||
|
||||
var attempt = await RunFfmpegThumbnailAsync(jobId, mp4Path, outPath, t, ct);
|
||||
if (attempt == ThumbAttempt.Wrote) return outPath;
|
||||
// A hung or unstartable ffmpeg will hang the same way a second
|
||||
// time, and each attempt costs the full two-minute cap.
|
||||
if (attempt == ThumbAttempt.Broken) return null;
|
||||
|
||||
// The seek is derived from what the AE build reported; if those
|
||||
// numbers disagree with the rendered file, ffmpeg lands past the
|
||||
// end and writes nothing. Falling back to the whole-file scan means
|
||||
// a wrong seek costs picture content, never the thumbnail itself.
|
||||
_log.LogWarning(
|
||||
"Thumbnail seek to {Seek:F2}s produced nothing for job {JobId} — retrying across the whole file. " +
|
||||
"The frame will likely be the slate.", t, jobId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.LogInformation(
|
||||
"Thumbnail for job {JobId}: build reported no picture start, scanning the whole file " +
|
||||
"(the frame will likely be the slate).", jobId);
|
||||
}
|
||||
|
||||
if (await RunFfmpegThumbnailAsync(jobId, mp4Path, outPath, null, ct) == ThumbAttempt.Wrote) return outPath;
|
||||
|
||||
_log.LogWarning("No thumbnail produced for job {JobId} — see the ffmpeg output above.", jobId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>How one ffmpeg attempt ended.</summary>
|
||||
private enum ThumbAttempt
|
||||
{
|
||||
/// <summary>A thumbnail was written.</summary>
|
||||
Wrote,
|
||||
/// <summary>ffmpeg ran and wrote nothing — a different attempt may work.</summary>
|
||||
NoOutput,
|
||||
/// <summary>ffmpeg hung or could not start — retrying costs minutes and fixes nothing.</summary>
|
||||
Broken,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One ffmpeg invocation. <paramref name="seekSeconds"/> null means the old
|
||||
/// whole-file <c>thumbnail</c> filter. Logs ffmpeg's own diagnosis when no
|
||||
/// file is produced.
|
||||
/// </summary>
|
||||
private async Task<ThumbAttempt> RunFfmpegThumbnailAsync(
|
||||
string jobId, string mp4Path, string outPath, double? seekSeconds, CancellationToken ct)
|
||||
{
|
||||
TryDelete(outPath);
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _options.FfmpegPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
var args = new List<string> { "-y" };
|
||||
if (seekSeconds is { } t)
|
||||
{
|
||||
// Before -i: ffmpeg seeks by keyframe, which is fast and accurate
|
||||
// enough for a poster frame.
|
||||
args.Add("-ss");
|
||||
args.Add(t.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture));
|
||||
args.Add("-i");
|
||||
args.Add(mp4Path);
|
||||
args.Add("-vf");
|
||||
args.Add("scale=960:-1");
|
||||
}
|
||||
else
|
||||
{
|
||||
args.Add("-i");
|
||||
args.Add(mp4Path);
|
||||
args.Add("-vf");
|
||||
args.Add("thumbnail,scale=960:-1");
|
||||
}
|
||||
args.Add("-frames:v");
|
||||
args.Add("1");
|
||||
args.Add(outPath);
|
||||
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
{
|
||||
_log.LogWarning("Could not start ffmpeg at {Path} for job {JobId}", _options.FfmpegPath, jobId);
|
||||
return ThumbAttempt.Broken;
|
||||
}
|
||||
|
||||
// Drain BOTH pipes while the process runs. Windows gives an
|
||||
// anonymous pipe a ~4 KB buffer; ffmpeg's banner and stream summary
|
||||
// exceed that on their own, so a redirected-but-unread pipe fills,
|
||||
// ffmpeg blocks forever on the write, and the wait below times out
|
||||
// with the process still alive. That is a deadlock, not a slow
|
||||
// encode, and it is why this step failed with nothing but a
|
||||
// cancellation to show for it.
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromMinutes(2));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Our own 2-minute cap, not a shutdown: leaving ffmpeg running
|
||||
// would hold the scratch file open and leak a process per job.
|
||||
_log.LogWarning(
|
||||
"ffmpeg timed out after 2 minutes for job {JobId} — killing it. Command: {Args}",
|
||||
jobId, string.Join(" ", args));
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
return ThumbAttempt.Broken;
|
||||
}
|
||||
var stderr = await stderrTask;
|
||||
await stdoutTask;
|
||||
|
||||
if (File.Exists(outPath) && new FileInfo(outPath).Length > 0) return ThumbAttempt.Wrote;
|
||||
|
||||
_log.LogWarning(
|
||||
"ffmpeg wrote no thumbnail for job {JobId} (exit {Exit}). Command: {Args} -- ffmpeg said: {Stderr}",
|
||||
jobId, process.ExitCode, string.Join(" ", args), Tail(stderr, 1200));
|
||||
return ThumbAttempt.NoOutput;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Thumbnail generation failed for job {JobId} (non-fatal)", jobId);
|
||||
return ThumbAttempt.Broken;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Last <paramref name="max"/> characters — ffmpeg puts the error at the end.</summary>
|
||||
private static string Tail(string? text, int max) =>
|
||||
string.IsNullOrEmpty(text) ? "(no output)"
|
||||
: text.Length <= max ? text
|
||||
: text[^max..];
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp for the poster frame: a quarter of the way into picture,
|
||||
/// skipping the slate. Null when the build did not report where picture
|
||||
/// starts, or when the numbers do not describe a real clip.
|
||||
/// </summary>
|
||||
internal static double? ThumbnailSeekSeconds(PreviewBuildResult? build, double fps)
|
||||
{
|
||||
if (build?.PictureStartSeconds is not { } start || start < 0) return null;
|
||||
if (build.FrameCount is not { } frames || frames <= 0) return null;
|
||||
if (fps <= 0) return null;
|
||||
return start + (frames / fps) * 0.25;
|
||||
}
|
||||
|
||||
private static PreviewStageResult Fail(string message, bool retryable, Stopwatch sw, PreviewBuildResult? build = null)
|
||||
{
|
||||
sw.Stop();
|
||||
return new PreviewStageResult(false, retryable, message, null, null, null, build?.TempAep, build, sw.Elapsed.TotalSeconds);
|
||||
}
|
||||
|
||||
internal static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VFXReviewWorker;
|
||||
|
||||
// VFXReview RenderWorker (RenderPipeline2 §7) — Windows service driving
|
||||
// aerender.exe on artist workstations / render nodes. All state flows through
|
||||
// /api/ext/* with API-key auth; the worker never touches the database.
|
||||
//
|
||||
// Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json]
|
||||
// Install as a service: see RenderWorker/README.md
|
||||
|
||||
// Dry-run the validation engine over an existing render folder. No server, no
|
||||
// config, no queue — useful for checking work that predates the pipeline.
|
||||
if (args.Any(a => a.Equals("--validate", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return ValidateCommand.Run(args);
|
||||
}
|
||||
|
||||
var configPath = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||
|
||||
// Install verification on a new machine — resolves paths, checks tools and
|
||||
// pings the server, then exits. Never starts the service loop.
|
||||
if (args.Any(a => a.Equals("--check", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return await Preflight.RunAsync(configPath);
|
||||
}
|
||||
|
||||
var options = WorkerOptions.Load(configPath);
|
||||
|
||||
// Positional args (config path) are consumed above and deliberately not
|
||||
// forwarded — the command-line configuration provider rejects bare tokens.
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Services.AddWindowsService(o => o.ServiceName = "VFXReviewRenderWorker");
|
||||
builder.Logging.AddProvider(new FileLoggerProvider());
|
||||
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton<ApiClient>();
|
||||
builder.Services.AddSingleton<ReportSpooler>();
|
||||
builder.Services.AddSingleton<AerenderRunner>();
|
||||
builder.Services.AddSingleton<PreviewStage>();
|
||||
builder.Services.AddSingleton<ValidationStage>();
|
||||
builder.Services.AddSingleton<DeliveryStage>();
|
||||
builder.Services.AddSingleton<BlenderRunner>();
|
||||
builder.Services.AddSingleton<JobExecutor>();
|
||||
builder.Services.AddHostedService<WorkerService>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
return 0;
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Parses aerender stdout line-by-line (§7.3): PROGRESS lines yield the
|
||||
/// current frame; ERROR lines are collected; deterministic errors (missing
|
||||
/// footage/comp/project) mark the job non-retryable — humans fix the comp.
|
||||
/// </summary>
|
||||
public static partial class ProgressParser
|
||||
{
|
||||
// e.g. "PROGRESS: 0:00:02:03 (51): 0 Seconds" / "PROGRESS: 2 (2): ..."
|
||||
[GeneratedRegex(@"^PROGRESS:.*\((\d+)\)", RegexOptions.Compiled)]
|
||||
private static partial Regex ProgressLine();
|
||||
|
||||
[GeneratedRegex(@"aerender\s+(SYNTAX\s+)?ERROR|^ERROR:", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ErrorLine();
|
||||
|
||||
private static readonly string[] NonRetryablePatterns =
|
||||
{
|
||||
"missing footage",
|
||||
"layer source file missing",
|
||||
"file is missing",
|
||||
"no comp was found",
|
||||
"no composition",
|
||||
"project file could not be opened",
|
||||
"can not be opened",
|
||||
"cannot be opened",
|
||||
"the file format module could not parse the file",
|
||||
"no render settings template",
|
||||
"no output module template",
|
||||
"syntax error",
|
||||
"illegal argument",
|
||||
};
|
||||
|
||||
/// <summary>Returns the rendered-frame ordinal (1-based within the range) or null.</summary>
|
||||
public static int? ParseFrame(string line)
|
||||
{
|
||||
var m = ProgressLine().Match(line);
|
||||
return m.Success && int.TryParse(m.Groups[1].Value, out var f) ? f : null;
|
||||
}
|
||||
|
||||
public static bool IsErrorLine(string line) => ErrorLine().IsMatch(line);
|
||||
|
||||
/// <summary>Deterministic failures re-render identically — don't burn retries on them.</summary>
|
||||
public static bool IsNonRetryableError(string line)
|
||||
{
|
||||
foreach (var p in NonRetryablePatterns)
|
||||
{
|
||||
if (line.Contains(p, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ETA = rolling average seconds/frame × frames remaining (§7.3).</summary>
|
||||
public sealed class EtaCalculator
|
||||
{
|
||||
private readonly int _windowSize;
|
||||
private readonly Queue<double> _frameSeconds = new();
|
||||
private DateTimeOffset? _lastFrameAt;
|
||||
|
||||
public EtaCalculator(int windowSize = 10) => _windowSize = windowSize;
|
||||
|
||||
public void RecordFrame(DateTimeOffset now)
|
||||
{
|
||||
if (_lastFrameAt is { } last)
|
||||
{
|
||||
_frameSeconds.Enqueue((now - last).TotalSeconds);
|
||||
while (_frameSeconds.Count > _windowSize) _frameSeconds.Dequeue();
|
||||
}
|
||||
_lastFrameAt = now;
|
||||
}
|
||||
|
||||
public int? EstimateSeconds(int framesRemaining)
|
||||
{
|
||||
if (_frameSeconds.Count == 0 || framesRemaining <= 0) return null;
|
||||
return (int)Math.Round(_frameSeconds.Average() * framesRemaining);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Durable lifecycle reports (§7.6): fail/complete reports are written to the
|
||||
/// spool directory before the first send attempt and replayed strictly in
|
||||
/// order with exponential backoff (1 s → 60 s). Rendering continues during
|
||||
/// server downtime; a report is deleted only once the server accepts it (or
|
||||
/// permanently rejects it as contradictory).
|
||||
///
|
||||
/// Strict ordering is load-bearing beyond durability: the validation report
|
||||
/// (E12) is only legal once complete-render (E11) has moved the Export to
|
||||
/// VALIDATING, and in-order replay is what guarantees that, with no need for
|
||||
/// the worker to wait on an ACK.
|
||||
/// </summary>
|
||||
public sealed class ReportSpooler
|
||||
{
|
||||
private readonly ApiClient _api;
|
||||
private readonly ILogger<ReportSpooler> _log;
|
||||
private readonly string _dir;
|
||||
private readonly SemaphoreSlim _wake = new(0);
|
||||
private long _seq;
|
||||
|
||||
public ReportSpooler(ApiClient api, ILogger<ReportSpooler> log, string? dir = null)
|
||||
{
|
||||
_api = api;
|
||||
_log = log;
|
||||
_dir = dir ?? WorkerOptions.SpoolDir;
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Enqueue(string kind, string jobId, Dictionary<string, object?> payload)
|
||||
{
|
||||
var report = new SpooledReport { Kind = kind, JobId = jobId, Payload = payload };
|
||||
var name = $"{DateTimeOffset.UtcNow.UtcTicks:D20}_{Interlocked.Increment(ref _seq):D6}.json";
|
||||
var tmp = Path.Combine(_dir, name + ".tmp");
|
||||
var final = Path.Combine(_dir, name);
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(report, WorkerOptions.JsonOpts));
|
||||
File.Move(tmp, final);
|
||||
_wake.Release();
|
||||
}
|
||||
|
||||
public bool HasPending => Directory.EnumerateFiles(_dir, "*.json").Any();
|
||||
|
||||
/// <summary>Long-running replay loop; started once by the worker service.</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = TimeSpan.FromSeconds(1);
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var next = Directory.EnumerateFiles(_dir, "*.json").OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault();
|
||||
if (next is null)
|
||||
{
|
||||
try { await _wake.WaitAsync(TimeSpan.FromSeconds(5), ct); } catch (OperationCanceledException) { break; }
|
||||
continue;
|
||||
}
|
||||
|
||||
bool done;
|
||||
try
|
||||
{
|
||||
var report = JsonSerializer.Deserialize<SpooledReport>(File.ReadAllText(next), WorkerOptions.JsonOpts);
|
||||
if (report is null || string.IsNullOrEmpty(report.JobId))
|
||||
{
|
||||
_log.LogWarning("Discarding unreadable spool file {File}", next);
|
||||
done = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
done = await _api.SendLifecycleAsync(report.Kind, report.JobId, report.Payload, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Spool replay attempt failed for {File}", next);
|
||||
done = false;
|
||||
}
|
||||
|
||||
if (done)
|
||||
{
|
||||
try { File.Delete(next); } catch { /* re-sent next pass; server side is idempotent */ }
|
||||
backoff = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
try { await Task.Delay(backoff, ct); } catch (OperationCanceledException) { break; }
|
||||
backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>VFXReviewWorker</RootNamespace>
|
||||
<AssemblyName>VFXReviewWorker</AssemblyName>
|
||||
<!-- Single self-contained exe for render nodes: dotnet publish -r win-x64 (see README) -->
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!--
|
||||
The version is a property of the build, not of the machine it lands on:
|
||||
it is stamped here and read back from the assembly at runtime, so a
|
||||
workstation cannot report a version it is not actually running. Bump on
|
||||
every change that gets published. publish.ps1 appends the git SHA via
|
||||
SourceRevisionId, giving "1.1.0+feb1c7b".
|
||||
-->
|
||||
<Version>1.6.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="VFXReviewWorker.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Headless AE build script ships beside the exe. CopyToPublishDirectory is
|
||||
set explicitly: CopyToOutputDirectory alone governs the build folder, and
|
||||
an incremental publish then leaves the script out of a freshly-cleaned
|
||||
publish directory. publish.ps1 copies it again and verifies, because the
|
||||
exe cannot run a preview job without it.
|
||||
-->
|
||||
<Content Include="..\scripts\vfxr_build_preview.jsx" Link="scripts\vfxr_build_preview.jsx">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// `VFXReviewWorker.exe --validate <renderDir> [--plate <plateFile>] [--fps 24]`
|
||||
///
|
||||
/// Runs the validation engine (§8) over a folder of already-rendered EXRs and
|
||||
/// prints the report, without a server, a job or a queue. Two uses: proving the
|
||||
/// checks against real work before the stage goes live, and answering "is this
|
||||
/// delivery good?" for a sequence that predates the pipeline.
|
||||
/// </summary>
|
||||
public static class ValidateCommand
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
var dir = args.SkipWhile(a => !a.Equals("--validate", StringComparison.OrdinalIgnoreCase)).Skip(1).FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(dir) || dir.StartsWith('-'))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: VFXReviewWorker.exe --validate <renderDir> [--plate <plateFirstFrame.exr>] [--fps 24] [--sample 25]");
|
||||
return 2;
|
||||
}
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
Console.Error.WriteLine($"Not a directory: {dir}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var plate = ArgValue(args, "--plate");
|
||||
var fps = double.TryParse(ArgValue(args, "--fps"), out var f) ? f : 24;
|
||||
var sample = int.TryParse(ArgValue(args, "--sample"), out var s) ? s : 25;
|
||||
|
||||
var files = Directory.EnumerateFiles(dir, "*.exr").OrderBy(x => x, StringComparer.Ordinal).ToList();
|
||||
if (files.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"No .exr files in {dir}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Infer what the manifest would have said from the files themselves.
|
||||
var firstName = Path.GetFileName(files[0]);
|
||||
var frames = files.Select(x => SequencePattern.FrameFromName(Path.GetFileName(x))).Where(x => x is not null).Select(x => x!.Value).ToList();
|
||||
var prefix = firstName.Contains('.') ? firstName[..firstName.IndexOf('.')] : Path.GetFileNameWithoutExtension(firstName);
|
||||
var header = ExrHeader.TryRead(files[0]);
|
||||
|
||||
var manifest = new RenderManifest
|
||||
{
|
||||
ShotCode = prefix.Split("_cmp_")[0],
|
||||
OutputDir = dir,
|
||||
OutputPattern = $"{prefix}.[#####].exr",
|
||||
FrameStart = frames.Count > 0 ? frames.Min() : 1,
|
||||
FrameEnd = frames.Count > 0 ? frames.Max() : files.Count,
|
||||
Fps = fps,
|
||||
Width = header?.DataWidth ?? 0,
|
||||
Height = header?.DataHeight ?? 0,
|
||||
Source = plate is null ? null : new ManifestSource
|
||||
{
|
||||
TimingPlate = new PlateRef { Path = plate, FrameStart = SequencePattern.FrameFromName(Path.GetFileName(plate)) },
|
||||
StartSource = "supplied on the command line",
|
||||
},
|
||||
};
|
||||
|
||||
var options = new WorkerOptions { ServerUrl = "n/a", ApiKey = "n/a" };
|
||||
var stage = new ValidationStage(options, NullLogger<ValidationStage>.Instance);
|
||||
var outcome = stage.Run(manifest, dir, new ServerConfig { ValidationSampleEvery = sample });
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"{files.Count} files in {dir}");
|
||||
Console.WriteLine($"inferred range {manifest.FrameStart}-{manifest.FrameEnd} at {fps} fps, pattern {manifest.OutputPattern}");
|
||||
if (plate is not null) Console.WriteLine($"timing plate {plate}");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var c in outcome.Checks)
|
||||
{
|
||||
var mark = c.Status switch { "PASS" => " ok ", "FAIL" => " FAIL ", "WARN" => " warn ", _ => " skip " };
|
||||
Console.WriteLine($"[{mark}] {c.CheckName,-24} {Describe(c)}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"checksum (xxh64): {outcome.Checksum ?? "(not computed)"}");
|
||||
var failed = outcome.Checks.Count(c => c.Status == "FAIL");
|
||||
Console.WriteLine(failed == 0 ? "PASSED" : $"FAILED — {failed} check(s)");
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static string Describe(ValidationCheck c)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(c.Message)) return c.Message!;
|
||||
if (c.Expected is null && c.Actual is null) return "";
|
||||
return c.Expected == c.Actual ? c.Actual ?? "" : $"expected {c.Expected}, got {c.Actual}";
|
||||
}
|
||||
|
||||
private static string? ArgValue(string[] args, string name)
|
||||
{
|
||||
var i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Validation engine (RenderPipeline2 §8). Runs on the worker straight after a
|
||||
/// successful render, while it still holds the job and the files are local.
|
||||
/// It only ever *reports* — the server decides what the results mean.
|
||||
///
|
||||
/// Full-sequence checks (count, gaps, names, sizes) touch every file; header
|
||||
/// checks sample first/middle/last plus every Nth frame.
|
||||
/// </summary>
|
||||
public sealed class ValidationStage
|
||||
{
|
||||
private readonly WorkerOptions _options;
|
||||
private readonly ILogger<ValidationStage> _log;
|
||||
|
||||
public ValidationStage(WorkerOptions options, ILogger<ValidationStage> log)
|
||||
{
|
||||
_options = options;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public ValidationOutcome Run(RenderManifest manifest, string outputDirLocal, ServerConfig config)
|
||||
{
|
||||
var checks = new List<ValidationCheck>();
|
||||
var files = EnumerateSequence(manifest, outputDirLocal, checks, out var frameNumbers, out var totalBytes);
|
||||
|
||||
if (files.Count == 0)
|
||||
{
|
||||
// Nothing to inspect: the sequence checks already recorded the FAIL.
|
||||
return new ValidationOutcome(checks, 0, 0, null);
|
||||
}
|
||||
|
||||
var sampleEvery = Math.Max(1, config.ValidationSampleEvery);
|
||||
var sampled = PickSamples(files, sampleEvery);
|
||||
var headers = ReadHeaders(sampled, checks);
|
||||
|
||||
if (headers.Count > 0)
|
||||
{
|
||||
CheckResolution(manifest, headers, checks);
|
||||
CheckBitDepthAndChannels(manifest, headers, checks);
|
||||
CheckCompression(manifest, headers, checks);
|
||||
CheckTimecode(manifest, headers, frameNumbers, config, checks);
|
||||
}
|
||||
|
||||
var checksum = ComputeSequenceChecksum(files);
|
||||
return new ValidationOutcome(checks, files.Count, totalBytes, checksum);
|
||||
}
|
||||
|
||||
// ── Sequence-level checks ────────────────────────────────────────────────
|
||||
|
||||
private List<string> EnumerateSequence(
|
||||
RenderManifest manifest, string outputDirLocal, List<ValidationCheck> checks,
|
||||
out List<int> frameNumbers, out long totalBytes)
|
||||
{
|
||||
frameNumbers = new List<int>();
|
||||
totalBytes = 0;
|
||||
var expectedCount = manifest.TotalFrames;
|
||||
|
||||
if (!Directory.Exists(outputDirLocal))
|
||||
{
|
||||
checks.Add(ValidationCheck.Fail("file_count", expectedCount.ToString(), "0",
|
||||
$"Output directory does not exist on this machine: {outputDirLocal}"));
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var pattern = SequencePattern.FromOutputPattern(manifest.OutputPattern);
|
||||
var matched = new List<(int Frame, string Path)>();
|
||||
var nonConforming = new List<string>();
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(outputDirLocal))
|
||||
{
|
||||
var name = Path.GetFileName(path);
|
||||
if (!name.EndsWith(pattern.Extension, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
// Only files belonging to this render: a version folder can also hold
|
||||
// the preview MOV/MP4, which are not part of the EXR sequence.
|
||||
if (!name.StartsWith(pattern.Prefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var frame = pattern.FrameNumberOf(name);
|
||||
if (frame is null) { nonConforming.Add(name); continue; }
|
||||
matched.Add((frame.Value, path));
|
||||
}
|
||||
|
||||
matched.Sort((a, b) => a.Frame.CompareTo(b.Frame));
|
||||
var files = matched.Select(m => m.Path).ToList();
|
||||
frameNumbers = matched.Select(m => m.Frame).ToList();
|
||||
|
||||
// file_count
|
||||
checks.Add(files.Count == expectedCount
|
||||
? ValidationCheck.Pass("file_count", expectedCount.ToString(), files.Count.ToString())
|
||||
: ValidationCheck.Fail("file_count", expectedCount.ToString(), files.Count.ToString(),
|
||||
$"Expected {expectedCount} frames ({manifest.FrameStart}-{manifest.FrameEnd}), found {files.Count} in {outputDirLocal}"));
|
||||
|
||||
// filename_consistency
|
||||
checks.Add(nonConforming.Count == 0
|
||||
? ValidationCheck.Pass("filename_consistency", pattern.Describe(), "all files match")
|
||||
: ValidationCheck.Fail("filename_consistency", pattern.Describe(), $"{nonConforming.Count} nonconforming",
|
||||
"Files not matching the output pattern: " + string.Join(", ", nonConforming.Take(10)),
|
||||
new { files = nonConforming.Take(50) }));
|
||||
|
||||
if (files.Count == 0) return files;
|
||||
|
||||
// Output files are numbered by the output module's starting number
|
||||
// (house convention: every sequence starts at 1001), which is a
|
||||
// different coordinate system from the comp's frame range — a comp
|
||||
// anchored to the plate's timecode starts at 1247792. Checking file
|
||||
// numbers against comp frames compares the two and fails every render,
|
||||
// so the sequence is checked for the right *count* and *contiguity*,
|
||||
// and its absolute start only where the manifest states it.
|
||||
var expectedFirst = manifest.OutputFrameStart;
|
||||
|
||||
var seen = new HashSet<int>();
|
||||
var duplicates = new List<int>();
|
||||
foreach (var f in frameNumbers)
|
||||
{
|
||||
if (!seen.Add(f)) duplicates.Add(f);
|
||||
}
|
||||
|
||||
// Gaps are found relative to the run the render actually produced: a
|
||||
// hole in the middle is a real defect regardless of where it starts.
|
||||
var runStart = expectedFirst ?? frameNumbers[0];
|
||||
var runEnd = runStart + expectedCount - 1;
|
||||
var missing = new List<int>();
|
||||
for (var f = runStart; f <= runEnd; f++)
|
||||
{
|
||||
if (!seen.Contains(f)) missing.Add(f);
|
||||
}
|
||||
|
||||
checks.Add(missing.Count == 0
|
||||
? ValidationCheck.Pass("missing_frames", "none", "none")
|
||||
: ValidationCheck.Fail("missing_frames", "none", $"{missing.Count} missing",
|
||||
$"Missing frames in {runStart}-{runEnd}: " + Summarise(missing),
|
||||
new { missing = missing.Take(500), runStart, runEnd }));
|
||||
|
||||
checks.Add(duplicates.Count == 0
|
||||
? ValidationCheck.Pass("duplicate_frames", "none", "none")
|
||||
: ValidationCheck.Fail("duplicate_frames", "none", $"{duplicates.Count} duplicated",
|
||||
"Duplicate frame numbers (differing padding?): " + Summarise(duplicates),
|
||||
new { duplicates = duplicates.Take(100) }));
|
||||
|
||||
if (expectedFirst is int first)
|
||||
{
|
||||
checks.Add(frameNumbers[0] == first
|
||||
? ValidationCheck.Pass("first_frame", first.ToString(), frameNumbers[0].ToString())
|
||||
: ValidationCheck.Fail("first_frame", first.ToString(), frameNumbers[0].ToString(),
|
||||
$"Sequence starts at {frameNumbers[0]}, expected {first}"));
|
||||
|
||||
var expectedLast = first + expectedCount - 1;
|
||||
checks.Add(frameNumbers[^1] == expectedLast
|
||||
? ValidationCheck.Pass("last_frame", expectedLast.ToString(), frameNumbers[^1].ToString())
|
||||
: ValidationCheck.Fail("last_frame", expectedLast.ToString(), frameNumbers[^1].ToString(),
|
||||
$"Sequence ends at {frameNumbers[^1]}, expected {expectedLast}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Old manifests carry no output numbering; the count and contiguity
|
||||
// checks above still hold, so the run is reported rather than
|
||||
// measured against a number that was never sent.
|
||||
checks.Add(ValidationCheck.Skipped("first_frame",
|
||||
$"manifest carries no output frame numbering — sequence runs {frameNumbers[0]}-{frameNumbers[^1]}"));
|
||||
}
|
||||
|
||||
// zero_byte — every file, it is only a directory stat
|
||||
var zeroByte = new List<string>();
|
||||
foreach (var path in files)
|
||||
{
|
||||
var len = new FileInfo(path).Length;
|
||||
totalBytes += len;
|
||||
if (len == 0) zeroByte.Add(Path.GetFileName(path));
|
||||
}
|
||||
checks.Add(zeroByte.Count == 0
|
||||
? ValidationCheck.Pass("zero_byte", "none", "none")
|
||||
: ValidationCheck.Fail("zero_byte", "none", $"{zeroByte.Count} empty",
|
||||
"Zero-byte files: " + string.Join(", ", zeroByte.Take(10)), new { files = zeroByte.Take(50) }));
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Header checks (sampled) ──────────────────────────────────────────────
|
||||
|
||||
private static List<string> PickSamples(List<string> files, int sampleEvery)
|
||||
{
|
||||
var picks = new SortedSet<int> { 0, files.Count / 2, files.Count - 1 };
|
||||
for (var i = 0; i < files.Count; i += sampleEvery) picks.Add(i);
|
||||
return picks.Where(i => i >= 0 && i < files.Count).Select(i => files[i]).ToList();
|
||||
}
|
||||
|
||||
private List<(string Path, ExrHeader Header)> ReadHeaders(List<string> sampled, List<ValidationCheck> checks)
|
||||
{
|
||||
var headers = new List<(string, ExrHeader)>();
|
||||
var unreadable = new List<string>();
|
||||
|
||||
foreach (var path in sampled)
|
||||
{
|
||||
try
|
||||
{
|
||||
headers.Add((path, ExrHeader.Read(path)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
unreadable.Add($"{Path.GetFileName(path)}: {ex.Message}");
|
||||
_log.LogWarning("Unreadable EXR header: {Path} ({Error})", path, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
checks.Add(unreadable.Count == 0
|
||||
? ValidationCheck.Pass("corrupt", "all sampled frames readable", $"{headers.Count} sampled")
|
||||
: ValidationCheck.Fail("corrupt", "all sampled frames readable", $"{unreadable.Count} unreadable",
|
||||
"Unreadable EXR headers: " + string.Join("; ", unreadable.Take(5)), new { files = unreadable.Take(50) }));
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static void CheckResolution(RenderManifest m, List<(string Path, ExrHeader H)> headers, List<ValidationCheck> checks)
|
||||
{
|
||||
if (m.Width <= 0 || m.Height <= 0)
|
||||
{
|
||||
checks.Add(ValidationCheck.Skipped("resolution", "manifest carries no expected resolution"));
|
||||
return;
|
||||
}
|
||||
var expected = $"{m.Width}x{m.Height}";
|
||||
var bad = headers.Where(h => h.H.DataWidth != m.Width || h.H.DataHeight != m.Height).ToList();
|
||||
checks.Add(bad.Count == 0
|
||||
? ValidationCheck.Pass("resolution", expected, expected)
|
||||
: ValidationCheck.Fail("resolution", expected, $"{bad[0].H.DataWidth}x{bad[0].H.DataHeight}",
|
||||
$"{bad.Count} of {headers.Count} sampled frames are not {expected} (first: {Path.GetFileName(bad[0].Path)})"));
|
||||
}
|
||||
|
||||
private static void CheckBitDepthAndChannels(RenderManifest m, List<(string Path, ExrHeader H)> headers, List<ValidationCheck> checks)
|
||||
{
|
||||
var expectedDepth = m.Expected?.BitDepth;
|
||||
var actualDepth = headers[0].H.BitDepth ?? "unknown";
|
||||
if (string.IsNullOrWhiteSpace(expectedDepth))
|
||||
{
|
||||
checks.Add(ValidationCheck.Skipped("bit_depth", $"no expected bit depth configured (rendered {actualDepth})"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var mismatched = headers.Where(h => !string.Equals(h.H.BitDepth, expectedDepth, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
checks.Add(mismatched.Count == 0
|
||||
? ValidationCheck.Pass("bit_depth", expectedDepth, actualDepth)
|
||||
: ValidationCheck.Fail("bit_depth", expectedDepth, mismatched[0].H.BitDepth ?? "unknown",
|
||||
$"{mismatched.Count} of {headers.Count} sampled frames are not {expectedDepth}"));
|
||||
}
|
||||
|
||||
var hasAlpha = headers[0].H.HasAlpha;
|
||||
var channelList = string.Join(",", headers[0].H.Channels.Select(c => c.Name));
|
||||
if (m.Expected?.Alpha is null)
|
||||
{
|
||||
checks.Add(ValidationCheck.Skipped("alpha_channel", $"no expectation configured (channels: {channelList})"));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(hasAlpha == m.Expected.Alpha.Value
|
||||
? ValidationCheck.Pass("alpha_channel", m.Expected.Alpha.Value ? "alpha present" : "no alpha", channelList)
|
||||
: ValidationCheck.Fail("alpha_channel",
|
||||
m.Expected.Alpha.Value ? "alpha present" : "no alpha",
|
||||
hasAlpha ? "alpha present" : "no alpha",
|
||||
$"Channel list is {channelList}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CheckCompression(RenderManifest m, List<(string Path, ExrHeader H)> headers, List<ValidationCheck> checks)
|
||||
{
|
||||
var actual = headers[0].H.CompressionName;
|
||||
if (headers[0].H.CompressionCode is null)
|
||||
{
|
||||
checks.Add(ValidationCheck.Fail("exr_type", "readable EXR", "no compression attribute",
|
||||
"Sampled frame has no compression attribute — not a valid EXR header"));
|
||||
return;
|
||||
}
|
||||
var expected = m.Expected?.ExrCompression;
|
||||
if (string.IsNullOrWhiteSpace(expected))
|
||||
{
|
||||
checks.Add(ValidationCheck.Pass("exr_type", "valid EXR", $"{actual}{(headers[0].H.IsTiled ? ", tiled" : ", scanline")}"));
|
||||
return;
|
||||
}
|
||||
// Compression differing but readable is a WARN, never a stop (§8.1).
|
||||
checks.Add(string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)
|
||||
? ValidationCheck.Pass("exr_type", expected, actual)
|
||||
: ValidationCheck.Warn("exr_type", expected, actual,
|
||||
$"Compression is {actual}, expected {expected} — readable, so not blocking"));
|
||||
}
|
||||
|
||||
// ── Timecode: the primary check (§8.1a) ──────────────────────────────────
|
||||
|
||||
private void CheckTimecode(
|
||||
RenderManifest m, List<(string Path, ExrHeader H)> headers, List<int> frameNumbers,
|
||||
ServerConfig config, List<ValidationCheck> checks)
|
||||
{
|
||||
var fps = m.Fps > 0 ? m.Fps : 24;
|
||||
var first = headers[0];
|
||||
|
||||
if (first.H.Timecode is null)
|
||||
{
|
||||
// AE's EXR output module writes timeCode; its absence means the
|
||||
// output module changed, so this is a stop rather than a skip.
|
||||
checks.Add(ValidationCheck.Fail("timecode_plate_match", "timecode present", "absent",
|
||||
$"No timeCode attribute in {Path.GetFileName(first.Path)} — the EXR output module no longer writes timecode",
|
||||
new { attributes = first.H.AttributeNames.Take(40) }));
|
||||
return;
|
||||
}
|
||||
|
||||
var renderTc = first.H.Timecode.Value;
|
||||
var renderFirstFrame = frameNumbers.Count > 0 ? frameNumbers[0] : m.FrameStart;
|
||||
|
||||
// timecode_consistency — sampled frames must advance exactly one frame
|
||||
// each. A constant value is the frame-derived-timecode defect.
|
||||
var drift = new List<string>();
|
||||
for (var i = 0; i < headers.Count; i++)
|
||||
{
|
||||
var h = headers[i].H;
|
||||
if (h.Timecode is null) { drift.Add($"{Path.GetFileName(headers[i].Path)}: no timecode"); continue; }
|
||||
var frameNo = SequencePattern.FrameFromName(Path.GetFileName(headers[i].Path)) ?? (renderFirstFrame + i);
|
||||
var expectedTc = Timecode.FromFrames(renderTc.ToFrames(fps) + (frameNo - renderFirstFrame), fps);
|
||||
if (h.Timecode.Value.ToFrames(fps) != expectedTc.ToFrames(fps))
|
||||
{
|
||||
drift.Add($"frame {frameNo}: {h.Timecode.Value} (expected {expectedTc})");
|
||||
}
|
||||
}
|
||||
checks.Add(drift.Count == 0
|
||||
? ValidationCheck.Pass("timecode_consistency", "monotonic, 1 frame per frame", $"{headers.Count} frames sampled")
|
||||
: ValidationCheck.Fail("timecode_consistency", "monotonic, 1 frame per frame", $"{drift.Count} sampled frames off",
|
||||
"Timecode does not advance with the frames: " + string.Join("; ", drift.Take(5)),
|
||||
new { drift = drift.Take(50) }));
|
||||
|
||||
// timecode_fps
|
||||
if (first.H.FramesPerSecond is double headerFps)
|
||||
{
|
||||
checks.Add(Math.Abs(headerFps - fps) < 0.01
|
||||
? ValidationCheck.Pass("timecode_fps", fps.ToString("0.###"), headerFps.ToString("0.###"))
|
||||
: ValidationCheck.Fail("timecode_fps", fps.ToString("0.###"), headerFps.ToString("0.###"),
|
||||
$"EXR framesPerSecond is {headerFps:0.###} but the comp rendered at {fps:0.###}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Add(ValidationCheck.Skipped("timecode_fps", "no framesPerSecond attribute in the rendered EXRs"));
|
||||
}
|
||||
|
||||
// timecode_plate_match — reference precedence, rank 1 → 3.
|
||||
var reference = ResolveTimecodeReference(m, config, renderFirstFrame);
|
||||
if (reference is null)
|
||||
{
|
||||
checks.Add(ValidationCheck.Warn("timecode_plate_match", "plate timecode", renderTc.ToString(),
|
||||
"No plate reference available: the manifest carries no timing plate, none was found on disk, " +
|
||||
"and no API timecode was supplied. Render timecode is unverified.",
|
||||
new { renderTimecode = renderTc.ToString() }));
|
||||
return;
|
||||
}
|
||||
|
||||
// plate_fps — the render's rate against the plate's own.
|
||||
//
|
||||
// timecode_fps only compares the render to the manifest, and both come
|
||||
// from the comp: a shot built at the wrong rate agrees with itself and
|
||||
// passes. The plate is the independent witness. A 25 fps show whose
|
||||
// shot records defaulted to 24 renders every shot at 24, and this is
|
||||
// what names that rather than leaving it to surface as an off-by-N
|
||||
// timecode error.
|
||||
if (reference.PlateFps is double plateFps && first.H.FramesPerSecond is double renderFps)
|
||||
{
|
||||
var ratio = renderFps > 0 ? plateFps / renderFps : 0;
|
||||
var overcrank = ratio > 1.5 && Math.Abs(ratio - Math.Round(ratio)) < 0.01;
|
||||
|
||||
checks.Add(Math.Abs(plateFps - renderFps) < 0.01
|
||||
? ValidationCheck.Pass("plate_fps", plateFps.ToString("0.###"), renderFps.ToString("0.###"))
|
||||
// Never a FAIL: a plate deliberately shot at a multiple of the
|
||||
// show rate is a legitimate over-crank, conformed on purpose.
|
||||
// Anything else is a rate mistake, and the wording says which.
|
||||
: ValidationCheck.Warn("plate_fps", plateFps.ToString("0.###"), renderFps.ToString("0.###"),
|
||||
overcrank
|
||||
? $"The plate is {plateFps:0.###} fps against a {renderFps:0.###} fps render — a {Math.Round(ratio)}x over-crank, conformed as expected."
|
||||
: $"The plate is {plateFps:0.###} fps but this rendered at {renderFps:0.###} — not a whole-number over-crank, so the comp was probably built at the wrong rate (check the shot's fps in the database).",
|
||||
new { platePath = reference.PlatePath, reference = reference.Kind, ratio }));
|
||||
}
|
||||
|
||||
var expectedFrames = reference.PlateTimecode.ToFrames(fps) + (renderFirstFrame - reference.PlateFirstFrame);
|
||||
var expected = Timecode.FromFrames(expectedFrames, fps);
|
||||
var actualFrames = renderTc.ToFrames(fps);
|
||||
var deltaFrames = actualFrames - expectedFrames;
|
||||
var details = new
|
||||
{
|
||||
reference = reference.Kind,
|
||||
platePath = reference.PlatePath,
|
||||
plateTimecode = reference.PlateTimecode.ToString(),
|
||||
plateFirstFrame = reference.PlateFirstFrame,
|
||||
renderFirstFrame,
|
||||
deltaFrames,
|
||||
};
|
||||
|
||||
if (deltaFrames == 0)
|
||||
{
|
||||
// The API fallback proves nothing on its own (§8.1a): it is the
|
||||
// editorial in-point minus a handle count that may not be this
|
||||
// plate's, so a match is reported as a WARN, never a PASS.
|
||||
checks.Add(reference.Kind == "api"
|
||||
? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
|
||||
"Matches the API timecode less handles, but the plate itself could not be read — unverified.", details)
|
||||
: ValidationCheck.Pass("timecode_plate_match", expected.ToString(), renderTc.ToString(),
|
||||
$"Matches {reference.Kind} plate {Path.GetFileName(reference.PlatePath ?? "")}", details));
|
||||
return;
|
||||
}
|
||||
|
||||
var message =
|
||||
$"Render starts at {renderTc} but the {reference.Kind} plate says it should be {expected} " +
|
||||
$"({(deltaFrames > 0 ? "+" : "")}{deltaFrames} frames). " +
|
||||
(Math.Abs(deltaFrames) == reference.HandleFrames && reference.HandleFrames > 0
|
||||
? $"That is exactly the {reference.HandleFrames}-frame handle: the comp was anchored to the editorial in-point instead of the plate's first frame."
|
||||
: renderTc.Hours == 0 && renderTc.Minutes == 0
|
||||
? "A near-zero timecode means the comp had no source timecode at all and AE derived it from the frame number."
|
||||
: "");
|
||||
|
||||
checks.Add(reference.Kind == "api"
|
||||
? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
|
||||
message + " (checked against the API value, not the plate — treat as advisory)", details)
|
||||
: ValidationCheck.Fail("timecode_plate_match", expected.ToString(), renderTc.ToString(), message, details));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference precedence (§8.1a): manifest timing plate → plate found under a
|
||||
/// configured search root → API timecode minus handles (advisory only).
|
||||
/// </summary>
|
||||
private TimecodeReference? ResolveTimecodeReference(RenderManifest m, ServerConfig config, int renderFirstFrame)
|
||||
{
|
||||
var mapper = new PathMapper(_options.PathMappings);
|
||||
var handleFrames = m.Source?.HandleFrames ?? config.ValidationHandleFrames;
|
||||
|
||||
// Rank 1 — the plate the comp was actually built from.
|
||||
var timing = m.Source?.TimingPlate;
|
||||
if (timing is not null && !string.IsNullOrWhiteSpace(timing.Path))
|
||||
{
|
||||
var local = mapper.Map(timing.Path);
|
||||
var header = File.Exists(local) ? ExrHeader.TryRead(local) : null;
|
||||
if (header?.Timecode is not null)
|
||||
{
|
||||
// The plate's own file numbering, never the comp's frame range:
|
||||
// the render's first frame is in output numbering, so anchoring
|
||||
// on a comp frame skews the comparison by the gap between them.
|
||||
var frame = SequencePattern.FrameFromName(Path.GetFileName(local))
|
||||
?? timing.FrameStart
|
||||
?? renderFirstFrame;
|
||||
return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames,
|
||||
header.FramesPerSecond);
|
||||
}
|
||||
_log.LogWarning("Timing plate from manifest is unreadable: {Path}", local);
|
||||
}
|
||||
|
||||
// Rank 2 — derive the plate from a configured search root.
|
||||
var derived = FindPlateOnDisk(m, mapper);
|
||||
if (derived is not null) return derived with { HandleFrames = handleFrames };
|
||||
|
||||
// Rank 3 — the API's editorial in-point, less handles. Advisory only.
|
||||
//
|
||||
// Shot.timecodeStart is the *edit* in-point; the plate is delivered with
|
||||
// handleFrames of head handle, so the first delivered frame sits that
|
||||
// much earlier. The anchor is the render's own first frame, which is in
|
||||
// the same numbering as the files on disk — not the manifest's frame
|
||||
// range, which is the comp's timeline and a different coordinate system
|
||||
// entirely (mixing the two produced a nonsense million-frame offset).
|
||||
var apiTc = Timecode.Parse(m.Expected?.TimecodeStart);
|
||||
if (apiTc is not null)
|
||||
{
|
||||
var fps = m.Fps > 0 ? m.Fps : 24;
|
||||
var plateStart = Timecode.FromFrames(apiTc.Value.ToFrames(fps) - handleFrames, fps);
|
||||
return new TimecodeReference("api", null, plateStart, renderFirstFrame, handleFrames);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rank 2: look for the shot's plate folders under the configured roots,
|
||||
/// mirroring the panel's two on-disk layouts ({shotCode}/{element}/ and
|
||||
/// {shotCode}_{element}/). Ambiguity is resolved by frame count; still
|
||||
/// ambiguous means no reference rather than a guess.
|
||||
/// </summary>
|
||||
private TimecodeReference? FindPlateOnDisk(RenderManifest m, PathMapper mapper)
|
||||
{
|
||||
if (_options.PlateSearchRoots.Count == 0 || string.IsNullOrWhiteSpace(m.ShotCode)) return null;
|
||||
|
||||
var candidates = new List<(string Dir, int Count)>();
|
||||
foreach (var rootRaw in _options.PlateSearchRoots)
|
||||
{
|
||||
var root = mapper.Map(rootRaw);
|
||||
if (!Directory.Exists(root)) continue;
|
||||
// The show/episode level is unknown here, so scan the root and one
|
||||
// level below it — bounded, and enough for both layouts.
|
||||
var levels = new List<string> { root };
|
||||
try { levels.AddRange(Directory.EnumerateDirectories(root)); } catch { }
|
||||
|
||||
foreach (var level in levels)
|
||||
{
|
||||
try
|
||||
{
|
||||
var grouped = Path.Combine(level, m.ShotCode);
|
||||
if (Directory.Exists(grouped))
|
||||
{
|
||||
foreach (var element in Directory.EnumerateDirectories(grouped))
|
||||
{
|
||||
AddCandidate(candidates, element);
|
||||
}
|
||||
}
|
||||
foreach (var element in Directory.EnumerateDirectories(level, m.ShotCode + "_*"))
|
||||
{
|
||||
AddCandidate(candidates, element);
|
||||
}
|
||||
}
|
||||
catch { /* unreadable branch — keep scanning the rest */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count == 0) return null;
|
||||
|
||||
// Prefer the element whose frame count matches the render: that is the
|
||||
// one the comp's timing came from.
|
||||
var exact = candidates.Where(c => c.Count == m.TotalFrames).ToList();
|
||||
(string Dir, int Count)? chosen =
|
||||
exact.Count == 1 ? exact[0]
|
||||
: exact.Count == 0 && candidates.Count == 1 ? candidates[0]
|
||||
: null;
|
||||
|
||||
if (chosen is null)
|
||||
{
|
||||
_log.LogWarning("Plate reference ambiguous for {Shot}: {Candidates}",
|
||||
m.ShotCode, string.Join(", ", candidates.Select(c => Path.GetFileName(c.Dir))));
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstFile = Directory.EnumerateFiles(chosen.Value.Dir, "*.exr").OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault();
|
||||
if (firstFile is null) return null;
|
||||
var header = ExrHeader.TryRead(firstFile);
|
||||
if (header?.Timecode is null) return null;
|
||||
|
||||
var frame = SequencePattern.FrameFromName(Path.GetFileName(firstFile)) ?? m.FrameStart;
|
||||
return new TimecodeReference("derived", firstFile, header.Timecode.Value, frame, 0, header.FramesPerSecond);
|
||||
}
|
||||
|
||||
private static void AddCandidate(List<(string Dir, int Count)> candidates, string dir)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = Directory.EnumerateFiles(dir, "*.exr").Count();
|
||||
if (count > 0) candidates.Add((dir, count));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ── Checksums ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Sequence digest = xxHash64 over the per-file digests in frame order
|
||||
/// (§8.2). Reused by the delivery builder to verify what it linked/copied.
|
||||
/// </summary>
|
||||
private string? ComputeSequenceChecksum(List<string> files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = new XxHash64.State(0);
|
||||
foreach (var f in files)
|
||||
{
|
||||
var digest = XxHash64.HashFile(f);
|
||||
state.Update(System.Text.Encoding.ASCII.GetBytes(digest));
|
||||
}
|
||||
return state.Digest().ToString("x16");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Sequence checksum failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Summarise(IEnumerable<int> numbers)
|
||||
{
|
||||
var list = numbers.Take(12).ToList();
|
||||
var text = string.Join(", ", list);
|
||||
return numbers.Count() > 12 ? text + ", …" : text;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TimecodeReference(
|
||||
string Kind, string? PlatePath, Timecode PlateTimecode, int PlateFirstFrame, int HandleFrames,
|
||||
double? PlateFps = null);
|
||||
|
||||
public sealed record ValidationOutcome(
|
||||
List<ValidationCheck> Checks, int FileCount, long TotalBytes, string? Checksum)
|
||||
{
|
||||
public bool HasFailure => Checks.Any(c => c.Status == "FAIL");
|
||||
}
|
||||
|
||||
/// <summary>One row of the E12 report.</summary>
|
||||
public sealed class ValidationCheck
|
||||
{
|
||||
public string CheckName { get; set; } = "";
|
||||
public string Status { get; set; } = "PASS";
|
||||
public string? Expected { get; set; }
|
||||
public string? Actual { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public object? Details { get; set; }
|
||||
|
||||
public static ValidationCheck Pass(string name, string? expected = null, string? actual = null, string? message = null, object? details = null) =>
|
||||
new() { CheckName = name, Status = "PASS", Expected = expected, Actual = actual, Message = message, Details = details };
|
||||
|
||||
public static ValidationCheck Fail(string name, string? expected, string? actual, string message, object? details = null) =>
|
||||
new() { CheckName = name, Status = "FAIL", Expected = expected, Actual = actual, Message = message, Details = details };
|
||||
|
||||
public static ValidationCheck Warn(string name, string? expected, string? actual, string message, object? details = null) =>
|
||||
new() { CheckName = name, Status = "WARN", Expected = expected, Actual = actual, Message = message, Details = details };
|
||||
|
||||
public static ValidationCheck Skipped(string name, string message) =>
|
||||
new() { CheckName = name, Status = "SKIPPED", Message = message };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses `{base}.[#####].exr` output patterns and the filenames they produce.
|
||||
/// </summary>
|
||||
public sealed class SequencePattern
|
||||
{
|
||||
public string Prefix { get; private init; } = "";
|
||||
public string Extension { get; private init; } = ".exr";
|
||||
public int Padding { get; private init; }
|
||||
|
||||
// Delivered plates separate the frame number with a dot
|
||||
// ("..._BG01_TT_v001.01001.exr"); AE-written renders use an underscore
|
||||
// ("..._PRERENDER_01001.exr"). Both appear as layers in a shot comp, so
|
||||
// both have to parse — a plate whose number cannot be read gets anchored on
|
||||
// a fallback, and a wrong anchor silently skews the timecode check.
|
||||
private static readonly Regex FrameInName = new(@"[._](\d{2,9})\.[A-Za-z0-9]+$", RegexOptions.Compiled);
|
||||
|
||||
public static SequencePattern FromOutputPattern(string outputPattern)
|
||||
{
|
||||
var pattern = outputPattern ?? "";
|
||||
var ext = Path.GetExtension(pattern);
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".exr";
|
||||
|
||||
var hashStart = pattern.IndexOf('[');
|
||||
var prefix = hashStart > 0 ? pattern[..hashStart].TrimEnd('.') : Path.GetFileNameWithoutExtension(pattern);
|
||||
var padding = 0;
|
||||
foreach (var ch in pattern)
|
||||
{
|
||||
if (ch == '#') padding++;
|
||||
}
|
||||
|
||||
return new SequencePattern { Prefix = prefix, Extension = ext, Padding = padding };
|
||||
}
|
||||
|
||||
public int? FrameNumberOf(string fileName) => FrameFromName(fileName);
|
||||
|
||||
/// <summary>`UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001.</summary>
|
||||
public static int? FrameFromName(string fileName)
|
||||
{
|
||||
var m = FrameInName.Match(fileName);
|
||||
if (!m.Success) return null;
|
||||
return int.TryParse(m.Groups[1].Value, out var n) ? n : null;
|
||||
}
|
||||
|
||||
public string Describe() => $"{Prefix}.[{new string('#', Math.Max(1, Padding))}]{Extension}";
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Machine-local config (RenderPipeline2 §7.8) — deliberately minimal;
|
||||
/// everything tunable lives in server SystemConfig and arrives via E6.
|
||||
/// Default path: C:\ProgramData\VFXReviewWorker\config.json
|
||||
/// </summary>
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
public string ServerUrl { get; set; } = "";
|
||||
public string ApiKey { get; set; } = "";
|
||||
public string MachineName { get; set; } = Environment.MachineName;
|
||||
public string AerenderPath { get; set; } = @"C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\aerender.exe";
|
||||
/// <summary>AfterFX.com — the console AE used for the headless preview build. Defaults beside aerender.</summary>
|
||||
public string? AfterFxPath { get; set; }
|
||||
/// <summary>vfxr_build_preview.jsx — defaults to scripts\ beside the worker exe.</summary>
|
||||
public string? PreviewScriptPath { get; set; }
|
||||
public string? FfmpegPath { get; set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? BlenderPath { get; set; }
|
||||
public string? OiiotoolPath { get; set; }
|
||||
public List<PathMapping> PathMappings { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Optional footage roots searched for a shot's plates when a manifest
|
||||
/// carries no timing plate (rank 2 of §8.1a) — e.g. ["V:/_FOOTAGE/UNG"].
|
||||
/// Leave empty and old manifests simply fall through to the API fallback.
|
||||
/// </summary>
|
||||
public List<string> PlateSearchRoots { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Read from the assembly, not from config.json: a hand-maintained version
|
||||
/// string is exactly the thing that goes stale, and a machine reporting a
|
||||
/// version it is not running is worse than no version at all.
|
||||
/// Includes the git SHA when publish.ps1 stamped one ("1.1.0+feb1c7b").
|
||||
/// </summary>
|
||||
public string WorkerVersion =>
|
||||
typeof(WorkerOptions).Assembly
|
||||
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? typeof(WorkerOptions).Assembly.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
|
||||
public string? AeVersion { get; set; }
|
||||
|
||||
public static string DataDir =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "VFXReviewWorker");
|
||||
|
||||
public static string DefaultConfigPath => Path.Combine(DataDir, "config.json");
|
||||
public static string LogsDir => Path.Combine(DataDir, "logs");
|
||||
public static string SpoolDir => Path.Combine(DataDir, "spool");
|
||||
public static string ScratchDir => Path.Combine(DataDir, "scratch");
|
||||
public static string StateFilePath => Path.Combine(DataDir, "current-job.json");
|
||||
|
||||
/// <summary>AfterFX.com sits beside aerender.exe in a standard AE install.</summary>
|
||||
public string ResolveAfterFxPath()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(AfterFxPath)) return AfterFxPath;
|
||||
var dir = Path.GetDirectoryName(AerenderPath);
|
||||
return dir is null ? "AfterFX.com" : Path.Combine(dir, "AfterFX.com");
|
||||
}
|
||||
|
||||
public string ResolvePreviewScriptPath()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(PreviewScriptPath)) return PreviewScriptPath;
|
||||
return Path.Combine(AppContext.BaseDirectory, "scripts", "vfxr_build_preview.jsx");
|
||||
}
|
||||
|
||||
public static WorkerOptions Load(string? path = null)
|
||||
{
|
||||
path ??= DefaultConfigPath;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Worker config not found at {path}. Create it with serverUrl, apiKey and machineName (see RenderWorker/README.md).");
|
||||
}
|
||||
var json = File.ReadAllText(path);
|
||||
var opts = JsonSerializer.Deserialize<WorkerOptions>(json, JsonOpts)
|
||||
?? throw new InvalidOperationException($"Could not parse {path}");
|
||||
if (string.IsNullOrWhiteSpace(opts.ServerUrl)) throw new InvalidOperationException("config.json: serverUrl is required");
|
||||
if (string.IsNullOrWhiteSpace(opts.ApiKey)) throw new InvalidOperationException("config.json: apiKey is required");
|
||||
if (string.IsNullOrWhiteSpace(opts.MachineName)) opts.MachineName = Environment.MachineName;
|
||||
return opts;
|
||||
}
|
||||
|
||||
public static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class PathMapping
|
||||
{
|
||||
public string From { get; set; } = "";
|
||||
public string To { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Main loop (§7.2): register on startup, heartbeat on its own timer (the
|
||||
/// cancel-signal channel), claim-poll only when idle, one job at a time.
|
||||
/// Workers poll dumbly — all scheduling policy (render windows, Render Now,
|
||||
/// urgent priority) lives server-side in the claim endpoint (§7.10).
|
||||
/// </summary>
|
||||
public sealed class WorkerService : BackgroundService
|
||||
{
|
||||
private readonly WorkerOptions _options;
|
||||
private readonly ApiClient _api;
|
||||
private readonly JobExecutor _executor;
|
||||
private readonly ReportSpooler _spooler;
|
||||
private readonly ILogger<WorkerService> _log;
|
||||
|
||||
private string? _machineId;
|
||||
private ServerConfig _config = new();
|
||||
private volatile string? _currentJobId;
|
||||
private CancellationTokenSource? _cancelCurrentJob;
|
||||
private bool? _lastAeOpen;
|
||||
|
||||
public WorkerService(WorkerOptions options, ApiClient api, JobExecutor executor, ReportSpooler spooler, ILogger<WorkerService> log)
|
||||
{
|
||||
_options = options;
|
||||
_api = api;
|
||||
_executor = executor;
|
||||
_spooler = spooler;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_log.LogInformation("VFXReview RenderWorker {Version} starting as \"{Name}\" → {Server}",
|
||||
_options.WorkerVersion, _options.MachineName, _options.ServerUrl);
|
||||
|
||||
_ = Task.Run(() => _spooler.RunAsync(stoppingToken), stoppingToken);
|
||||
|
||||
await RegisterWithRetryAsync(stoppingToken);
|
||||
await _api.ReportVersionAsync(_options, stoppingToken);
|
||||
await _executor.ReconcileAsync(_machineId!, stoppingToken);
|
||||
|
||||
_ = Task.Run(() => HeartbeatLoopAsync(stoppingToken), stoppingToken);
|
||||
|
||||
// Claim loop — only while idle
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Preview builds drive a full AE instance, which would hijack an
|
||||
// artist's open session (§9.1). Simply don't ask for those jobs
|
||||
// while AE is running — they stay queued for a quiet moment
|
||||
// instead of being claimed and failed.
|
||||
var aeOpen = AeSession.InteractiveRunning();
|
||||
if (aeOpen != _lastAeOpen)
|
||||
{
|
||||
_log.LogInformation(aeOpen
|
||||
? "Interactive After Effects detected — not claiming preview jobs until it closes"
|
||||
: "No interactive After Effects — preview jobs enabled");
|
||||
_lastAeOpen = aeOpen;
|
||||
}
|
||||
// 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", "BLENDER_RENDER" }
|
||||
: new[] { "AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD", "BLENDER_RENDER" };
|
||||
|
||||
var job = await _api.ClaimAsync(_machineId!, types, stoppingToken);
|
||||
if (job is not null)
|
||||
{
|
||||
_log.LogInformation("Claimed job {JobId} (attempt {Attempt}/{Max}, export {ExportId})",
|
||||
job.Id, job.Attempt, job.MaxAttempts, job.ExportId);
|
||||
_cancelCurrentJob = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None);
|
||||
_currentJobId = job.Id;
|
||||
try
|
||||
{
|
||||
await _executor.RunAsync(job, _machineId!, _config, _cancelCurrentJob.Token, stoppingToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_currentJobId = null;
|
||||
_cancelCurrentJob.Dispose();
|
||||
_cancelCurrentJob = null;
|
||||
}
|
||||
continue; // immediately look for the next job
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Claim poll failed (server unreachable?) — retrying on normal cadence");
|
||||
}
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(_config.PollSeconds), stoppingToken); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
_log.LogInformation("RenderWorker stopping");
|
||||
}
|
||||
|
||||
private async Task RegisterWithRetryAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = TimeSpan.FromSeconds(1);
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reg = await _api.RegisterAsync(_options, ct);
|
||||
_machineId = reg.Machine.Id;
|
||||
_config = reg.Config;
|
||||
_log.LogInformation(
|
||||
"Registered as machine {Id} ({Name}); poll={Poll}s heartbeat={Hb}s lease={Lease}s stall={Stall}s enabled={Enabled}",
|
||||
reg.Machine.Id, reg.Machine.Name, _config.PollSeconds, _config.HeartbeatSeconds,
|
||||
_config.LeaseSeconds, _config.StallTimeoutSeconds, reg.Machine.Enabled);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("Registration failed ({Error}); retrying in {Backoff}s", ex.Message, backoff.TotalSeconds);
|
||||
try { await Task.Delay(backoff, ct); } catch (OperationCanceledException) { return; }
|
||||
backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 60));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HeartbeatLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await _api.HeartbeatAsync(_machineId!, _currentJobId, ct);
|
||||
foreach (var cmd in resp.Commands)
|
||||
{
|
||||
if (cmd.Type == "CANCEL_JOB" && cmd.JobId is not null && cmd.JobId == _currentJobId)
|
||||
{
|
||||
_log.LogWarning("Heartbeat carried CANCEL_JOB for current job {JobId}", cmd.JobId);
|
||||
_cancelCurrentJob?.Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex, "Heartbeat failed (non-fatal)");
|
||||
}
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(_config.HeartbeatSeconds), ct); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// xxHash64 (§8.2 `checksumAlgo: "xxh64"`). Implemented here rather than taken
|
||||
/// from System.IO.Hashing so the worker stays a dependency-free single-file
|
||||
/// publish for offline workstation installs. Verified against the reference
|
||||
/// test vectors in XxHash64Tests.
|
||||
/// </summary>
|
||||
public static class XxHash64
|
||||
{
|
||||
private const ulong Prime1 = 11400714785074694791UL;
|
||||
private const ulong Prime2 = 14029467366897019727UL;
|
||||
private const ulong Prime3 = 1609587929392839161UL;
|
||||
private const ulong Prime4 = 9650029242287828579UL;
|
||||
private const ulong Prime5 = 2870177450012600261UL;
|
||||
|
||||
public static ulong Hash(ReadOnlySpan<byte> data, ulong seed = 0)
|
||||
{
|
||||
var state = new State(seed);
|
||||
state.Update(data);
|
||||
return state.Digest();
|
||||
}
|
||||
|
||||
public static string HashFile(string path, int bufferSize = 1 << 20)
|
||||
{
|
||||
var state = new State(0);
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize, FileOptions.SequentialScan);
|
||||
var buffer = new byte[bufferSize];
|
||||
int read;
|
||||
while ((read = fs.Read(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
state.Update(new ReadOnlySpan<byte>(buffer, 0, read));
|
||||
}
|
||||
return state.Digest().ToString("x16");
|
||||
}
|
||||
|
||||
/// <summary>Streaming state so multi-GB sequences hash without being held in memory.</summary>
|
||||
public struct State
|
||||
{
|
||||
private ulong _v1, _v2, _v3, _v4;
|
||||
private readonly ulong _seed;
|
||||
private ulong _length;
|
||||
private readonly byte[] _buffer;
|
||||
private int _bufferLen;
|
||||
|
||||
public State(ulong seed)
|
||||
{
|
||||
_seed = seed;
|
||||
_v1 = seed + Prime1 + Prime2;
|
||||
_v2 = seed + Prime2;
|
||||
_v3 = seed;
|
||||
_v4 = seed - Prime1;
|
||||
_length = 0;
|
||||
_buffer = new byte[32];
|
||||
_bufferLen = 0;
|
||||
}
|
||||
|
||||
public void Update(ReadOnlySpan<byte> data)
|
||||
{
|
||||
_length += (ulong)data.Length;
|
||||
|
||||
if (_bufferLen > 0)
|
||||
{
|
||||
var need = Math.Min(32 - _bufferLen, data.Length);
|
||||
data[..need].CopyTo(_buffer.AsSpan(_bufferLen));
|
||||
_bufferLen += need;
|
||||
data = data[need..];
|
||||
if (_bufferLen < 32) return;
|
||||
ProcessStripe(_buffer);
|
||||
_bufferLen = 0;
|
||||
}
|
||||
|
||||
while (data.Length >= 32)
|
||||
{
|
||||
ProcessStripe(data[..32]);
|
||||
data = data[32..];
|
||||
}
|
||||
|
||||
if (data.Length > 0)
|
||||
{
|
||||
data.CopyTo(_buffer.AsSpan(0));
|
||||
_bufferLen = data.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessStripe(ReadOnlySpan<byte> stripe)
|
||||
{
|
||||
_v1 = Round(_v1, BitConverter.ToUInt64(stripe[..8]));
|
||||
_v2 = Round(_v2, BitConverter.ToUInt64(stripe.Slice(8, 8)));
|
||||
_v3 = Round(_v3, BitConverter.ToUInt64(stripe.Slice(16, 8)));
|
||||
_v4 = Round(_v4, BitConverter.ToUInt64(stripe.Slice(24, 8)));
|
||||
}
|
||||
|
||||
public ulong Digest()
|
||||
{
|
||||
ulong h;
|
||||
if (_length >= 32)
|
||||
{
|
||||
h = RotateLeft(_v1, 1) + RotateLeft(_v2, 7) + RotateLeft(_v3, 12) + RotateLeft(_v4, 18);
|
||||
h = MergeRound(h, _v1);
|
||||
h = MergeRound(h, _v2);
|
||||
h = MergeRound(h, _v3);
|
||||
h = MergeRound(h, _v4);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = _seed + Prime5;
|
||||
}
|
||||
|
||||
h += _length;
|
||||
|
||||
var tail = new ReadOnlySpan<byte>(_buffer, 0, _bufferLen);
|
||||
int i = 0;
|
||||
while (i + 8 <= tail.Length)
|
||||
{
|
||||
h ^= Round(0, BitConverter.ToUInt64(tail.Slice(i, 8)));
|
||||
h = RotateLeft(h, 27) * Prime1 + Prime4;
|
||||
i += 8;
|
||||
}
|
||||
if (i + 4 <= tail.Length)
|
||||
{
|
||||
h ^= BitConverter.ToUInt32(tail.Slice(i, 4)) * Prime1;
|
||||
h = RotateLeft(h, 23) * Prime2 + Prime3;
|
||||
i += 4;
|
||||
}
|
||||
while (i < tail.Length)
|
||||
{
|
||||
h ^= tail[i] * Prime5;
|
||||
h = RotateLeft(h, 11) * Prime1;
|
||||
i++;
|
||||
}
|
||||
|
||||
h ^= h >> 33;
|
||||
h *= Prime2;
|
||||
h ^= h >> 29;
|
||||
h *= Prime3;
|
||||
h ^= h >> 32;
|
||||
return h;
|
||||
}
|
||||
|
||||
private static ulong Round(ulong acc, ulong input)
|
||||
{
|
||||
acc += input * Prime2;
|
||||
acc = RotateLeft(acc, 31);
|
||||
acc *= Prime1;
|
||||
return acc;
|
||||
}
|
||||
|
||||
private static ulong MergeRound(ulong acc, ulong val)
|
||||
{
|
||||
acc ^= Round(0, val);
|
||||
acc = acc * Prime1 + Prime4;
|
||||
return acc;
|
||||
}
|
||||
|
||||
private static ulong RotateLeft(ulong value, int bits) => (value << bits) | (value >> (64 - bits));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Packages the RenderWorker for installation on another machine.
|
||||
|
||||
.DESCRIPTION
|
||||
Produces a self-contained single-file build (no .NET runtime needed on the
|
||||
target) plus the headless AE build script, and zips it for copying.
|
||||
|
||||
.EXAMPLE
|
||||
.\publish.ps1
|
||||
.\publish.ps1 -Zip
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutDir = "",
|
||||
[switch]$Zip
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$root = Split-Path $PSScriptRoot -Parent
|
||||
if (-not $OutDir) { $OutDir = Join-Path $root "publish\win-x64" }
|
||||
|
||||
# Stamp the commit into the version so a deployed exe can be traced back to
|
||||
# source ("1.1.0+feb1c7b"). Absent git, the plain version still publishes.
|
||||
$sha = ""
|
||||
try { $sha = (& git -C $root rev-parse --short HEAD 2>$null) } catch { }
|
||||
if ($LASTEXITCODE -ne 0) { $sha = "" }
|
||||
|
||||
Write-Host "Publishing self-contained worker..." -ForegroundColor Cyan
|
||||
$publishArgs = @(
|
||||
(Join-Path $root "VFXReviewWorker\VFXReviewWorker.csproj")
|
||||
"-c"; "Release"; "-r"; "win-x64"
|
||||
"--self-contained"; "true"
|
||||
"-p:PublishSingleFile=true"
|
||||
"-o"; $OutDir
|
||||
)
|
||||
if ($sha) { $publishArgs += "-p:SourceRevisionId=$sha" }
|
||||
& dotnet publish @publishArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" }
|
||||
|
||||
# The exe reads scripts\vfxr_build_preview.jsx from its own folder at runtime,
|
||||
# so it is copied here unconditionally rather than trusted to the SDK: an
|
||||
# incremental publish into a cleaned output directory silently skips content
|
||||
# copying, which produced a package that looked fine and could not build a
|
||||
# preview. Copy, then verify.
|
||||
$scriptSrc = Join-Path $PSScriptRoot "vfxr_build_preview.jsx"
|
||||
$scriptOut = Join-Path $OutDir "scripts\vfxr_build_preview.jsx"
|
||||
if (-not (Test-Path $scriptSrc)) { throw "Preview build script not found in source: $scriptSrc" }
|
||||
New-Item -ItemType Directory -Force (Split-Path $scriptOut) | Out-Null
|
||||
Copy-Item $scriptSrc $scriptOut -Force
|
||||
if (-not (Test-Path $scriptOut)) { throw "Preview build script missing from output: $scriptOut" }
|
||||
|
||||
Remove-Item (Join-Path $OutDir "*.pdb") -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Package contents:" -ForegroundColor Green
|
||||
Get-ChildItem $OutDir -Recurse -File | ForEach-Object {
|
||||
Write-Host (" {0,-40} {1,8:N0} KB" -f $_.FullName.Substring($OutDir.Length).TrimStart('\'), ($_.Length / 1KB))
|
||||
}
|
||||
|
||||
if ($Zip) {
|
||||
$zipPath = Join-Path $root ("VFXReviewWorker_{0}.zip" -f (Get-Date -Format "yyyyMMdd"))
|
||||
if (Test-Path $zipPath) { Remove-Item $zipPath }
|
||||
Compress-Archive -Path (Join-Path $OutDir "*") -DestinationPath $zipPath
|
||||
Write-Host ""
|
||||
Write-Host "Zipped to: $zipPath" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Next on the target machine:" -ForegroundColor Cyan
|
||||
Write-Host " 1. Copy this folder to e.g. C:\pipeline\VFXReviewWorker"
|
||||
Write-Host " 2. Create C:\ProgramData\VFXReviewWorker\config.json (see README)"
|
||||
Write-Host " 3. Verify: .\VFXReviewWorker.exe --check"
|
||||
Write-Host " 4. Install: sc.exe create VFXReviewRenderWorker binPath= ... start= auto obj= ... password= ..."
|
||||
@@ -0,0 +1,138 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Headless AE spike / debug tool for the preview build stage.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs vfxr_build_preview.jsx exactly the way the RenderWorker does, but
|
||||
standalone, so the risky part (AfterFX.com -noui -r under a service
|
||||
account) can be proven before wiring the whole pipeline together.
|
||||
|
||||
It does NOT render - it builds the preview comp, queues the MOV + MP4
|
||||
output modules and saves a temp AEP, then prints the build result.
|
||||
|
||||
IMPORTANT: close After Effects first. `AfterFX.com -r` hands the script to
|
||||
an already-running instance, which would open the template in your session
|
||||
and then quit it.
|
||||
|
||||
.EXAMPLE
|
||||
.\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\UNG_S1\111\UNG_111_001_030\v002" `
|
||||
-ShotCode "UNG_111_001_030" -Version "v002"
|
||||
|
||||
.EXAMPLE
|
||||
# then render what it built, exactly as the worker would:
|
||||
& "C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\aerender.exe" -project "$env:ProgramData\VFXReviewWorker\scratch\spike_preview.aep"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ExrDir,
|
||||
[Parameter(Mandatory = $true)][string]$ShotCode,
|
||||
[string]$Version = "v001",
|
||||
[string]$TemplateAep = "X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep",
|
||||
[string]$TemplateComp = "UNG_EXPORT_TEMPLATE",
|
||||
[string]$OverlayComp = "UNG_VFX_OVERLAY",
|
||||
[string]$LutComp = "_SHOW LUT",
|
||||
[string]$MovTemplate = "4444 Tri",
|
||||
[string]$Mp4Template = "REVIEW_PREVIEW",
|
||||
[string]$AfterFx = "C:\Program Files\Adobe\Adobe After Effects 2026\Support Files\AfterFX.com",
|
||||
[int]$FrameStart = 1001,
|
||||
[double]$Fps = 24,
|
||||
[int]$TimeoutSeconds = 300
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$running = Get-Process AfterFX -ErrorAction SilentlyContinue
|
||||
if ($running) {
|
||||
Write-Warning "After Effects is running (PID $($running.Id -join ', '))."
|
||||
Write-Warning "AfterFX.com -r would target that instance and quit it. Close AE first."
|
||||
return
|
||||
}
|
||||
|
||||
$scratch = Join-Path $env:ProgramData "VFXReviewWorker\scratch"
|
||||
if (-not (Test-Path $scratch)) { New-Item -ItemType Directory -Force -Path $scratch | Out-Null }
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot "vfxr_build_preview.jsx"
|
||||
if (-not (Test-Path $scriptPath)) { throw "Build script not found: $scriptPath" }
|
||||
if (-not (Test-Path $AfterFx)) { throw "AfterFX.com not found: $AfterFx" }
|
||||
if (-not (Test-Path $ExrDir)) { throw "EXR folder not found: $ExrDir" }
|
||||
|
||||
$contextPath = Join-Path $scratch "spike_context.json"
|
||||
$resultPath = Join-Path $scratch "spike_result.json"
|
||||
$wrapperPath = Join-Path $scratch "spike_run.jsx"
|
||||
$tempAep = Join-Path $scratch "spike_preview.aep"
|
||||
$base = "${ShotCode}_cmp_TT_${Version}"
|
||||
|
||||
Remove-Item $resultPath -ErrorAction SilentlyContinue
|
||||
|
||||
$context = [ordered]@{
|
||||
jobId = "spike"
|
||||
exportId = "spike"
|
||||
shotCode = $ShotCode
|
||||
versionString = $Version
|
||||
outputDirLocal = $ExrDir
|
||||
templateAep = $TemplateAep
|
||||
templateComp = $TemplateComp
|
||||
overlayComp = $OverlayComp
|
||||
lutComp = $LutComp
|
||||
movTemplate = $MovTemplate
|
||||
mp4Template = $Mp4Template
|
||||
movOutput = (Join-Path $ExrDir "$base.mov")
|
||||
mp4Output = (Join-Path $ExrDir "$base.mp4")
|
||||
tempAep = $tempAep
|
||||
resultPath = $resultPath
|
||||
frameStart = $FrameStart
|
||||
fps = $Fps
|
||||
slate = [ordered]@{
|
||||
versionName = $base
|
||||
date = (Get-Date -Format "yyyy/MM/dd")
|
||||
description = "Spike test"
|
||||
notes = ""
|
||||
shotCode = $ShotCode
|
||||
episode = ""
|
||||
scene = ""
|
||||
}
|
||||
}
|
||||
$context | ConvertTo-Json -Depth 6 | Out-File -FilePath $contextPath -Encoding utf8
|
||||
|
||||
$ctxJs = ($contextPath -replace '\\', '/') | ConvertTo-Json
|
||||
$scriptJs = ($scriptPath -replace '\\', '/') | ConvertTo-Json
|
||||
$wrapper = @(
|
||||
"// generated by test-preview-build.ps1",
|
||||
("var VFXR_CONTEXT_PATH = {0};" -f $ctxJs),
|
||||
('$.evalFile({0});' -f $scriptJs)
|
||||
) -join "`r`n"
|
||||
$wrapper | Out-File -FilePath $wrapperPath -Encoding utf8
|
||||
|
||||
Write-Host "Launching headless AE build..." -ForegroundColor Cyan
|
||||
$proc = Start-Process -FilePath $AfterFx -ArgumentList @("-noui", "-r", $wrapperPath) -PassThru -NoNewWindow
|
||||
if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
Write-Warning "Timed out after $TimeoutSeconds s - killing AE."
|
||||
try { $proc.Kill($true) } catch {}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $resultPath)) {
|
||||
Write-Host "FAILED: no result file produced." -ForegroundColor Red
|
||||
Write-Host "AE could not run the script headlessly under this account. See RenderWorker/README.md fallbacks." -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
|
||||
$result = Get-Content $resultPath -Raw | ConvertFrom-Json
|
||||
if ($result.ok) {
|
||||
Write-Host "BUILD OK" -ForegroundColor Green
|
||||
Write-Host " temp AEP : $($result.tempAep)"
|
||||
Write-Host " preview comp: $($result.previewComp)"
|
||||
Write-Host (" frames : {0} ({1} x {2})" -f $result.frameCount, $result.width, $result.height)
|
||||
Write-Host (" queued : {0} output(s)" -f @($result.queued).Count)
|
||||
foreach ($q in @($result.queued)) {
|
||||
Write-Host (" - {0}: {1} -> {2}" -f $q.label, $q.template, $q.output)
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Render it with:" -ForegroundColor Cyan
|
||||
Write-Host (' "{0}\aerender.exe" -project "{1}"' -f (Split-Path $AfterFx), $result.tempAep)
|
||||
} else {
|
||||
Write-Host "BUILD FAILED: $($result.error)" -ForegroundColor Red
|
||||
}
|
||||
if ($result.warnings -and $result.warnings.Count -gt 0) {
|
||||
Write-Host "Warnings:" -ForegroundColor Yellow
|
||||
$result.warnings | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow }
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
/*
|
||||
vfxr_build_preview.jsx — VFXReview render pipeline, preview build stage.
|
||||
|
||||
Runs headlessly: AfterFX.com -noui -r <wrapper>.jsx
|
||||
where the wrapper sets var VFXR_CONTEXT_PATH = "...json"; then
|
||||
$.evalFile()s this script.
|
||||
|
||||
It does NOT render. It opens the studio slate/overlay template, rebuilds
|
||||
the shot around the *rendered* EXR sequence (OCIO → show LUT → overlay),
|
||||
duplicates the export template into a preview comp with the slate filled
|
||||
in, queues the MOV and MP4 output modules, and saves a throwaway AEP.
|
||||
The worker then runs `aerender -project <that aep>` with no -comp, which
|
||||
renders the whole queue in one launch.
|
||||
|
||||
Build logic mirrors the VFXReviewConnector panel functions
|
||||
buildShotFromCode / updateOverlayForComp / buildPreviewForShot so the
|
||||
farm and the artist produce identical slates and burn-ins.
|
||||
|
||||
Everything is reported back through the result JSON — never a dialog.
|
||||
*/
|
||||
|
||||
(function vfxrBuildPreview() {
|
||||
var ctx = null;
|
||||
var result = {
|
||||
ok: false,
|
||||
error: null,
|
||||
warnings: [],
|
||||
tempAep: null,
|
||||
previewComp: null,
|
||||
queued: [],
|
||||
frameCount: null,
|
||||
width: null,
|
||||
height: null,
|
||||
// Seconds of slate ahead of picture in the preview comp — the SHOT
|
||||
// layer's in-point. The worker needs it to grab a thumbnail from
|
||||
// picture rather than from the slate card.
|
||||
pictureStartSeconds: null
|
||||
};
|
||||
var resultPath = null;
|
||||
|
||||
// ── tiny helpers (ES3 / ExtendScript safe) ───────────────────────────────
|
||||
|
||||
function readTextFile(path) {
|
||||
var f = new File(path);
|
||||
var text;
|
||||
if (!f.exists) {
|
||||
return null;
|
||||
}
|
||||
f.encoding = "UTF-8";
|
||||
if (!f.open("r")) {
|
||||
return null;
|
||||
}
|
||||
text = f.read();
|
||||
f.close();
|
||||
return text;
|
||||
}
|
||||
|
||||
function writeTextFile(path, text) {
|
||||
var f = new File(path);
|
||||
try {
|
||||
f.encoding = "UTF-8";
|
||||
if (!f.open("w")) {
|
||||
return false;
|
||||
}
|
||||
f.write(text);
|
||||
f.close();
|
||||
return true;
|
||||
} catch (writeError) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonEscape(value) {
|
||||
var text = String(value);
|
||||
var out = "";
|
||||
var i;
|
||||
var ch;
|
||||
var code;
|
||||
|
||||
for (i = 0; i < text.length; i += 1) {
|
||||
ch = text.charAt(i);
|
||||
code = text.charCodeAt(i);
|
||||
if (ch === "\"") {
|
||||
out += "\\\"";
|
||||
} else if (ch === "\\") {
|
||||
out += "\\\\";
|
||||
} else if (ch === "\n") {
|
||||
out += "\\n";
|
||||
} else if (ch === "\r") {
|
||||
out += "\\r";
|
||||
} else if (ch === "\t") {
|
||||
out += "\\t";
|
||||
} else if (code < 32) {
|
||||
out += "\\u" + ("000" + code.toString(16)).slice(-4);
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toJSON(value) {
|
||||
var parts = [];
|
||||
var i;
|
||||
var key;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return "null";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return isFinite(value) ? String(value) : "null";
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return "\"" + jsonEscape(value) + "\"";
|
||||
}
|
||||
if (value instanceof Array) {
|
||||
for (i = 0; i < value.length; i += 1) {
|
||||
parts.push(toJSON(value[i]));
|
||||
}
|
||||
return "[" + parts.join(",") + "]";
|
||||
}
|
||||
for (key in value) {
|
||||
if (value.hasOwnProperty(key) && value[key] !== undefined) {
|
||||
parts.push("\"" + jsonEscape(key) + "\":" + toJSON(value[key]));
|
||||
}
|
||||
}
|
||||
return "{" + parts.join(",") + "}";
|
||||
}
|
||||
|
||||
function warn(message) {
|
||||
result.warnings.push(String(message));
|
||||
}
|
||||
|
||||
function findComp(name) {
|
||||
var i;
|
||||
var item;
|
||||
|
||||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||||
item = app.project.item(i);
|
||||
if (item instanceof CompItem && item.name === name) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFolder(name) {
|
||||
var i;
|
||||
var item;
|
||||
|
||||
for (i = 1; i <= app.project.numItems; i += 1) {
|
||||
item = app.project.item(i);
|
||||
if (item instanceof FolderItem && item.name === name) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mirrors the panel's addOCIOToLayer: effect added but disabled, so the
|
||||
// artist/farm chain matches exactly.
|
||||
function addOCIOToLayer(layer) {
|
||||
var ocio;
|
||||
var outputProp;
|
||||
|
||||
try {
|
||||
ocio = layer.property("Effects").addProperty("OCIO Color Space Transform");
|
||||
ocio.enabled = false;
|
||||
} catch (ocioError) {
|
||||
warn("OCIO effect unavailable: " + ocioError.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
outputProp = ocio.property("Output Color Space");
|
||||
} catch (byNameError) {
|
||||
outputProp = null;
|
||||
}
|
||||
if (!outputProp) {
|
||||
try {
|
||||
outputProp = ocio.property(2);
|
||||
} catch (byIndexError) {
|
||||
outputProp = null;
|
||||
}
|
||||
}
|
||||
if (outputProp) {
|
||||
try {
|
||||
outputProp.setValue(93);
|
||||
} catch (setError) {
|
||||
warn("Could not set OCIO output colour space: " + setError.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setEssentialProperty(layer, propName, value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
layer.property("Essential Properties").property(propName).setValue(value);
|
||||
return true;
|
||||
} catch (setError) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setEssentialPropertyByIndex(layer, index, value) {
|
||||
try {
|
||||
layer.property("Essential Properties").property(index).setValue(value);
|
||||
return true;
|
||||
} catch (setError) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function listEssentialPropertyNames(layer) {
|
||||
var names = [];
|
||||
var group;
|
||||
var i;
|
||||
|
||||
try {
|
||||
group = layer.property("Essential Properties");
|
||||
for (i = 1; i <= group.numProperties; i += 1) {
|
||||
try {
|
||||
names.push(i + ":" + group.property(i).name);
|
||||
} catch (oneError) {
|
||||
}
|
||||
}
|
||||
} catch (groupError) {
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// Slate identity fields (version name, shot name). Current templates expose
|
||||
// these under a stable name, older ones only positionally, and the two do
|
||||
// not always line up — so write both. Setting the same property twice is
|
||||
// harmless; the panel uses the same name-with-index-fallback pattern.
|
||||
function setSlateIdentity(layer, propName, index, value, label) {
|
||||
var byName;
|
||||
var byIndex;
|
||||
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return; // leave the template's own default
|
||||
}
|
||||
byName = setEssentialProperty(layer, propName, value);
|
||||
byIndex = setEssentialPropertyByIndex(layer, index, value);
|
||||
if (!byName && !byIndex) {
|
||||
warn(label + ": neither Essential Property \"" + propName + "\" nor index " +
|
||||
index + " could be set. Available: " +
|
||||
listEssentialPropertyNames(layer).join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-submission fields (VFX Scope / Submission Note). The property names
|
||||
// come from config; if one does not exist, report the template's actual
|
||||
// property names so it can be corrected without another build.
|
||||
function setSlateField(layer, propName, value, label) {
|
||||
if (!propName) {
|
||||
return;
|
||||
}
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return; // nothing to write; leave the template's own default
|
||||
}
|
||||
if (!setEssentialProperty(layer, propName, value)) {
|
||||
warn(label + ": Essential Property \"" + propName + "\" not found or not settable. " +
|
||||
"Available: " + listEssentialPropertyNames(layer).join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
// The production name, burned into both the slate and the overlay. The
|
||||
// slate's other fields are title case ("Date", "Scene") while the overlay's
|
||||
// are upper ("DATE", "SHOT NAME"), so both spellings are attempted rather
|
||||
// than assuming which template uses which — and a template that exposes
|
||||
// neither reports its actual property names instead of failing silently.
|
||||
function setShowField(layer, value, label) {
|
||||
var candidates = ["SHOW", "Show"];
|
||||
var i;
|
||||
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < candidates.length; i += 1) {
|
||||
if (setEssentialProperty(layer, candidates[i], value)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
warn(label + ": no Essential Property named " + candidates.join(" or ") +
|
||||
". Available: " + listEssentialPropertyNames(layer).join(", "));
|
||||
}
|
||||
|
||||
function setCompStartFrame(comp, frameNumber) {
|
||||
try {
|
||||
comp.displayStartFrame = frameNumber;
|
||||
return;
|
||||
} catch (frameError) {
|
||||
}
|
||||
try {
|
||||
comp.displayStartTime = frameNumber / comp.frameRate;
|
||||
} catch (timeError) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyOutputModuleTemplate(outputModule, templateName) {
|
||||
var templates;
|
||||
var i;
|
||||
|
||||
try {
|
||||
templates = outputModule.templates;
|
||||
for (i = 0; i < templates.length; i += 1) {
|
||||
if (templates[i] === templateName) {
|
||||
outputModule.applyTemplate(templateName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (templateError) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function importExrSequence(dirPath) {
|
||||
var folder = new Folder(dirPath);
|
||||
var files;
|
||||
var importOptions;
|
||||
var footage;
|
||||
|
||||
if (!folder.exists) {
|
||||
throw new Error("Render output folder not found: " + dirPath);
|
||||
}
|
||||
|
||||
files = folder.getFiles("*.exr");
|
||||
if (!files || files.length < 1) {
|
||||
throw new Error("No EXR files found in " + dirPath);
|
||||
}
|
||||
files.sort();
|
||||
|
||||
importOptions = new ImportOptions(files[0]);
|
||||
importOptions.sequence = true;
|
||||
footage = app.project.importFile(importOptions);
|
||||
try {
|
||||
footage.mainSource.conformFrameRate = ctx.fps || 24;
|
||||
} catch (conformError) {
|
||||
warn("Could not conform frame rate: " + conformError.toString());
|
||||
}
|
||||
return footage;
|
||||
}
|
||||
|
||||
function queueOutput(comp, templateName, outputPath, label) {
|
||||
var item;
|
||||
var outputModule;
|
||||
|
||||
if (!templateName || !outputPath) {
|
||||
warn("Skipping " + label + ": no template or output path configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
item = app.project.renderQueue.items.add(comp);
|
||||
outputModule = item.outputModule(1);
|
||||
if (!applyOutputModuleTemplate(outputModule, templateName)) {
|
||||
item.remove();
|
||||
throw new Error("Output module template not found: \"" + templateName + "\"");
|
||||
}
|
||||
outputModule.file = new File(outputPath);
|
||||
result.queued.push({ label: label, template: templateName, output: outputPath });
|
||||
return true;
|
||||
} catch (queueError) {
|
||||
try {
|
||||
if (item) {
|
||||
item.remove();
|
||||
}
|
||||
} catch (removeError) {
|
||||
}
|
||||
throw queueError;
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
if (typeof VFXR_CONTEXT_PATH === "undefined" || !VFXR_CONTEXT_PATH) {
|
||||
throw new Error("VFXR_CONTEXT_PATH was not set by the wrapper script");
|
||||
}
|
||||
|
||||
var contextText = readTextFile(VFXR_CONTEXT_PATH);
|
||||
if (!contextText) {
|
||||
throw new Error("Could not read job context: " + VFXR_CONTEXT_PATH);
|
||||
}
|
||||
ctx = eval("(" + contextText + ")");
|
||||
resultPath = ctx.resultPath;
|
||||
|
||||
try {
|
||||
app.beginSuppressDialogs();
|
||||
} catch (suppressError) {
|
||||
}
|
||||
|
||||
// 1. Open the studio template (slate, overlay, show LUT, export template)
|
||||
var templateFile = new File(ctx.templateAep);
|
||||
if (!templateFile.exists) {
|
||||
throw new Error("Template project not found: " + ctx.templateAep);
|
||||
}
|
||||
app.open(templateFile);
|
||||
|
||||
var templateComp = findComp(ctx.templateComp);
|
||||
if (!templateComp) {
|
||||
throw new Error("Export template comp \"" + ctx.templateComp + "\" not found in template project");
|
||||
}
|
||||
|
||||
// 2. Import the rendered EXR sequence
|
||||
var footage = importExrSequence(ctx.outputDirLocal);
|
||||
var footageFolder = findFolder("_FOOTAGE_4K");
|
||||
if (footageFolder) {
|
||||
footage.parentFolder = footageFolder;
|
||||
}
|
||||
footage.name = ctx.shotCode + "_" + ctx.versionString;
|
||||
|
||||
var width = footage.width;
|
||||
var height = footage.height;
|
||||
var duration = footage.duration;
|
||||
var fps = ctx.fps || 24;
|
||||
|
||||
result.width = width;
|
||||
result.height = height;
|
||||
result.frameCount = Math.round(duration * fps);
|
||||
|
||||
// 3. Footage precomp (mirrors buildShotFromCode)
|
||||
var precompFolder = findFolder("_PRECOMPS");
|
||||
var footageComp = app.project.items.addComp(
|
||||
ctx.shotCode + "_FOOTAGE", width, height, 1, duration, fps);
|
||||
if (precompFolder) {
|
||||
footageComp.parentFolder = precompFolder;
|
||||
}
|
||||
if (ctx.frameStart !== null && ctx.frameStart !== undefined) {
|
||||
setCompStartFrame(footageComp, ctx.frameStart);
|
||||
}
|
||||
addOCIOToLayer(footageComp.layers.add(footage));
|
||||
|
||||
// 4. Shot comp: footage + show LUT + overlay
|
||||
var shotComp = app.project.items.addComp(ctx.shotCode, width, height, 1, duration, fps);
|
||||
if (ctx.frameStart !== null && ctx.frameStart !== undefined) {
|
||||
setCompStartFrame(shotComp, ctx.frameStart);
|
||||
}
|
||||
shotComp.layers.add(footageComp);
|
||||
|
||||
if (ctx.lutComp) {
|
||||
var showLut = findComp(ctx.lutComp);
|
||||
if (showLut) {
|
||||
var lutLayer = shotComp.layers.add(showLut);
|
||||
lutLayer.collapseTransformation = true;
|
||||
} else {
|
||||
warn("Show LUT comp \"" + ctx.lutComp + "\" not found — rendering without it");
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.overlayComp) {
|
||||
var overlayComp = findComp(ctx.overlayComp);
|
||||
if (overlayComp) {
|
||||
var overlayLayer = shotComp.layers.add(overlayComp);
|
||||
overlayLayer.moveToBeginning();
|
||||
overlayLayer.enabled = true;
|
||||
setEssentialProperty(overlayLayer, "DATE", ctx.slate.date);
|
||||
setEssentialProperty(overlayLayer, "SHOT NAME", ctx.slate.versionName);
|
||||
setShowField(overlayLayer, ctx.slate.show, "Overlay SHOW");
|
||||
} else {
|
||||
warn("Overlay comp \"" + ctx.overlayComp + "\" not found — rendering without burn-ins");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Preview comp from the export template (mirrors buildPreviewForShot)
|
||||
var previewsFolder = findFolder("_PREVIEWS");
|
||||
var previewComp = templateComp.duplicate();
|
||||
previewComp.name = ctx.shotCode + "_PREVIEW";
|
||||
if (previewsFolder) {
|
||||
previewComp.parentFolder = previewsFolder;
|
||||
}
|
||||
|
||||
previewComp.layer("SHOT").replaceSource(shotComp, false);
|
||||
try {
|
||||
previewComp.layer("THUMBNAIL").replaceSource(shotComp, false);
|
||||
} catch (thumbError) {
|
||||
warn("THUMBNAIL layer not updated: " + thumbError.toString());
|
||||
}
|
||||
|
||||
var slateLayer = previewComp.layer("NETFLIX_SLATE");
|
||||
if (slateLayer) {
|
||||
// Identity fields match the panel's indices (3 = version name,
|
||||
// 10 = shot name) and are also written by name; the rest are
|
||||
// addressed by name only.
|
||||
// VersionName carries the full "{shotCode}_cmp_TT_{version}";
|
||||
// ShotName is the bare shot code, with no version suffix.
|
||||
setSlateIdentity(slateLayer, "VersionName", 3, ctx.slate.versionName, "Version Name");
|
||||
setSlateIdentity(slateLayer, "ShotName", 10, ctx.slate.shotCode, "Shot Name");
|
||||
setEssentialProperty(slateLayer, "Date", ctx.slate.date);
|
||||
setEssentialProperty(slateLayer, "Desc", ctx.slate.description);
|
||||
setEssentialProperty(slateLayer, "Episode", ctx.slate.episode);
|
||||
setEssentialProperty(slateLayer, "Scene", ctx.slate.scene);
|
||||
setEssentialProperty(slateLayer, "Frames", result.frameCount);
|
||||
setShowField(slateLayer, ctx.slate.show, "Slate SHOW");
|
||||
|
||||
setSlateField(slateLayer, ctx.slateScopeProp, ctx.slate.vfxScope, "VFX Scope");
|
||||
// The slate's Notes field carries this submission's note; when the
|
||||
// artist left it blank, fall back to the shot's own notes so the
|
||||
// slate looks the same as an artist-built preview.
|
||||
setSlateField(slateLayer, ctx.slateSubmissionProp,
|
||||
(ctx.slate.submissionNote !== null &&
|
||||
ctx.slate.submissionNote !== undefined &&
|
||||
ctx.slate.submissionNote !== "")
|
||||
? ctx.slate.submissionNote
|
||||
: ctx.slate.notes,
|
||||
"Submission Note");
|
||||
} else {
|
||||
warn("NETFLIX_SLATE layer not found in template — slate fields not filled");
|
||||
}
|
||||
|
||||
var shotLayer = previewComp.layer("SHOT");
|
||||
var targetOutPoint = shotLayer.inPoint + shotComp.duration;
|
||||
// Everything before this is slate; the thumbnail has to start after it.
|
||||
result.pictureStartSeconds = shotLayer.inPoint;
|
||||
if (targetOutPoint > previewComp.duration) {
|
||||
previewComp.duration = targetOutPoint;
|
||||
}
|
||||
shotLayer.outPoint = targetOutPoint;
|
||||
previewComp.duration = targetOutPoint;
|
||||
|
||||
result.previewComp = previewComp.name;
|
||||
|
||||
// 6. Queue both outputs against the same preview comp. aerender will
|
||||
// render the whole queue in one launch.
|
||||
app.project.renderQueue.showWindow(false);
|
||||
while (app.project.renderQueue.numItems > 0) {
|
||||
app.project.renderQueue.item(1).remove();
|
||||
}
|
||||
queueOutput(previewComp, ctx.movTemplate, ctx.movOutput, "mov");
|
||||
queueOutput(previewComp, ctx.mp4Template, ctx.mp4Output, "mp4");
|
||||
|
||||
if (result.queued.length < 1) {
|
||||
throw new Error("Nothing was queued — check output module templates");
|
||||
}
|
||||
|
||||
// 7. Save the throwaway project the worker will hand to aerender
|
||||
var tempFile = new File(ctx.tempAep);
|
||||
var tempParent = tempFile.parent;
|
||||
if (tempParent && !tempParent.exists) {
|
||||
tempParent.create();
|
||||
}
|
||||
app.project.save(tempFile);
|
||||
result.tempAep = ctx.tempAep;
|
||||
result.ok = true;
|
||||
} catch (buildError) {
|
||||
result.ok = false;
|
||||
result.error = buildError && buildError.toString ? buildError.toString() : String(buildError);
|
||||
try {
|
||||
if (buildError && buildError.line) {
|
||||
result.error += " (line " + buildError.line + ")";
|
||||
}
|
||||
} catch (lineError) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
app.endSuppressDialogs(false);
|
||||
} catch (endSuppressError) {
|
||||
}
|
||||
|
||||
if (!resultPath && ctx && ctx.resultPath) {
|
||||
resultPath = ctx.resultPath;
|
||||
}
|
||||
if (resultPath) {
|
||||
writeTextFile(resultPath, toJSON(result));
|
||||
}
|
||||
|
||||
// -noui leaves the app running otherwise; the worker waits on exit.
|
||||
try {
|
||||
app.quit();
|
||||
} catch (quitError) {
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,753 @@
|
||||
# Technical Architecture Report (Current State)
|
||||
|
||||
Date: 2026-07-31
|
||||
Scope: Current implementation only (no redesign suggestions)
|
||||
|
||||
---
|
||||
|
||||
## 1. Database
|
||||
|
||||
Authoritative schema source: [prisma/schema.prisma](prisma/schema.prisma)
|
||||
|
||||
### Full Prisma Schema
|
||||
The full schema is defined in [prisma/schema.prisma](prisma/schema.prisma).
|
||||
|
||||
### Models related to requested domains
|
||||
|
||||
- Users/Auth
|
||||
- User
|
||||
- Account
|
||||
- Session
|
||||
- VerificationToken
|
||||
- ClientAccess
|
||||
- Projects/Episodes
|
||||
- Project
|
||||
- EpisodeDueDate
|
||||
- Client
|
||||
- Shots/Tasks/Versions/Reviews
|
||||
- Shot
|
||||
- ShotGroup
|
||||
- Task
|
||||
- Version
|
||||
- Comment
|
||||
- CommentReply
|
||||
- Annotation
|
||||
- Approval
|
||||
- ReviewSession
|
||||
- Files/Storage
|
||||
- FootagePlate
|
||||
- ShotReference
|
||||
- SystemConfig
|
||||
- Delivery/Export adjacent
|
||||
- Shot fields: highResKey, highResFilename, exrOutput, shotVersion
|
||||
- Version fields: fileUrl, fileName, proxyUrl, thumbnailUrl, posterUrl
|
||||
|
||||
### Relationship summary
|
||||
|
||||
- Client 1:N Project
|
||||
- Project 1:N Shot
|
||||
- Project 1:N Task
|
||||
- Project 1:N ReviewSession
|
||||
- Project 1:N EpisodeDueDate
|
||||
- Shot 1:N Task
|
||||
- Shot 1:N Version
|
||||
- Shot 1:N FootagePlate
|
||||
- Shot 1:N ShotReference
|
||||
- Task 1:N Version
|
||||
- Version 1:N Comment
|
||||
- Version 1:N Annotation
|
||||
- Version 1:N Approval
|
||||
- Comment 1:N CommentReply
|
||||
- User has many assigned/created entities across shots, tasks, versions, comments, approvals
|
||||
|
||||
### Existing status enums
|
||||
|
||||
From [prisma/schema.prisma](prisma/schema.prisma):
|
||||
|
||||
- ProjectStatus: ACTIVE, ON_HOLD, COMPLETED, ARCHIVED
|
||||
- ShotStatus: WAITING, IN_PROGRESS, INTERNAL_REVIEW, READY_FOR_CLIENT, CLIENT_REVIEW, REVISIONS, COMPLETE
|
||||
- ShotApprovalStatus: PENDING, INTERNALLY_APPROVED, CLIENT_APPROVED
|
||||
- TaskStatus: TODO, IN_PROGRESS, INTERNAL_REVIEW, CLIENT_REVIEW, CHANGES, DONE
|
||||
- ApprovalStatus: PENDING_REVIEW, APPROVED, REJECTED, NEEDS_CHANGES
|
||||
- ReviewStatus: PENDING, INTERNAL_APPROVED, CLIENT_APPROVED, NEEDS_CHANGES, FINAL_APPROVED
|
||||
|
||||
Notes:
|
||||
- There is no dedicated Delivery model.
|
||||
- There is no dedicated Export model.
|
||||
- Delivery/export state is represented by file pointers and shot/version metadata fields.
|
||||
|
||||
---
|
||||
|
||||
## 2. API
|
||||
|
||||
### 2.1 Shots
|
||||
|
||||
#### External shot APIs
|
||||
|
||||
- GET /api/ext/projects
|
||||
- URL: /api/ext/projects
|
||||
- Method: GET
|
||||
- Purpose: List projects for pipeline tools
|
||||
- Request body: None
|
||||
- Response: projects[] with id, name, code, showId, projectType, status, dates, _count
|
||||
- Source: [app/api/ext/projects/route.ts](app/api/ext/projects/route.ts)
|
||||
|
||||
- GET /api/ext/projects/{projectCode}/episodes
|
||||
- URL: /api/ext/projects/{projectCode}/episodes
|
||||
- Method: GET
|
||||
- Purpose: List distinct episodes and optionally shot payloads per episode
|
||||
- Request body: None
|
||||
- Response: project + episodes[]; optional shots[] includes exrOutput/timecodes
|
||||
- Source: [app/api/ext/projects/[projectCode]/episodes/route.ts](app/api/ext/projects/%5BprojectCode%5D/episodes/route.ts)
|
||||
|
||||
- GET /api/ext/projects/{projectCode}/shots
|
||||
- URL: /api/ext/projects/{projectCode}/shots
|
||||
- Method: GET
|
||||
- Purpose: List shots by project code with filters/pagination
|
||||
- Request body: None
|
||||
- Response: project + pagination + shots[]
|
||||
- Source: [app/api/ext/projects/[projectCode]/shots/route.ts](app/api/ext/projects/%5BprojectCode%5D/shots/route.ts)
|
||||
|
||||
- GET /api/ext/shots
|
||||
- URL: /api/ext/shots
|
||||
- Method: GET
|
||||
- Purpose: Legacy listing by projectId
|
||||
- Request body: None
|
||||
- Response: shots[] + total
|
||||
- Source: [app/api/ext/shots/route.ts](app/api/ext/shots/route.ts)
|
||||
|
||||
- POST /api/ext/shots
|
||||
- URL: /api/ext/shots
|
||||
- Method: POST
|
||||
- Purpose: Create shot from external tool (JSON or multipart thumbnail)
|
||||
- Request body:
|
||||
- projectId, scene
|
||||
- optional episode, description, artistId, priority, fps, frameStart, frameEnd, dueDate, thumbnailUrl, shotGroupName, shotCode
|
||||
- optional thumbnail file (multipart)
|
||||
- Response: created shot object
|
||||
- Source: [app/api/ext/shots/route.ts](app/api/ext/shots/route.ts)
|
||||
|
||||
- GET /api/ext/shots/lookup
|
||||
- URL: /api/ext/shots/lookup
|
||||
- Method: GET
|
||||
- Purpose: Canonical shot lookup by shotCode (+ optional projectCode)
|
||||
- Request body: None
|
||||
- Response: shot object with project, artist, tasks, latest version, exrOutput, shotVersion, source/seq timecodes
|
||||
- Source: [app/api/ext/shots/lookup/route.ts](app/api/ext/shots/lookup/route.ts)
|
||||
|
||||
- GET /api/ext/shots/{shotId}
|
||||
- URL: /api/ext/shots/{shotId}
|
||||
- Method: GET
|
||||
- Purpose: Shot detail by DB id, or byCode mode
|
||||
- Request body: None
|
||||
- Response: full shot detail with tasks/latest version/counts
|
||||
- Source: [app/api/ext/shots/[shotId]/route.ts](app/api/ext/shots/%5BshotId%5D/route.ts)
|
||||
|
||||
- PATCH /api/ext/shots/{shotId}
|
||||
- URL: /api/ext/shots/{shotId}
|
||||
- Method: PATCH
|
||||
- Purpose: Update mutable shot field(s) from pipeline tools
|
||||
- Request body: shotVersion (v###) currently supported
|
||||
- Response: success + updated shot id/shotVersion
|
||||
- Source: [app/api/ext/shots/[shotId]/route.ts](app/api/ext/shots/%5BshotId%5D/route.ts)
|
||||
|
||||
#### Internal shot APIs
|
||||
|
||||
- GET /api/shots/{shotId}
|
||||
- URL: /api/shots/{shotId}
|
||||
- Method: GET
|
||||
- Purpose: Internal dashboard shot detail payload
|
||||
- Request body: None
|
||||
- Response: shot + tasks + artists + permissions flags
|
||||
- Source: [app/api/shots/[shotId]/route.ts](app/api/shots/%5BshotId%5D/route.ts)
|
||||
|
||||
- GET /api/projects/{projectId}/episodes
|
||||
- URL: /api/projects/{projectId}/episodes
|
||||
- Method: GET
|
||||
- Purpose: Internal distinct episode list for project
|
||||
- Request body: None
|
||||
- Response: episodes[]
|
||||
- Source: [app/api/projects/[projectId]/episodes/route.ts](app/api/projects/%5BprojectId%5D/episodes/route.ts)
|
||||
|
||||
### 2.2 Reviews
|
||||
|
||||
- GET /api/review-sessions
|
||||
- Purpose: list review sessions
|
||||
- POST /api/review-sessions
|
||||
- Purpose: create review session token and portal link
|
||||
- DELETE /api/review-sessions
|
||||
- Purpose: deactivate review session
|
||||
- Source: [app/api/review-sessions/route.ts](app/api/review-sessions/route.ts)
|
||||
|
||||
- POST /api/client/{token}/auth
|
||||
- Purpose: review password check + unlock cookie
|
||||
- Source: [app/api/client/[token]/auth/route.ts](app/api/client/%5Btoken%5D/auth/route.ts)
|
||||
|
||||
- GET /api/client/{token}/project
|
||||
- Purpose: client portal project payload (shared shots/versions)
|
||||
- Source: [app/api/client/[token]/project/route.ts](app/api/client/%5Btoken%5D/project/route.ts)
|
||||
|
||||
- GET /api/client/{token}/versions/{versionId}
|
||||
- Purpose: client review detail payload
|
||||
- Source: [app/api/client/[token]/versions/[versionId]/route.ts](app/api/client/%5Btoken%5D/versions/%5BversionId%5D/route.ts)
|
||||
|
||||
- POST /api/client/{token}/comment
|
||||
- Purpose: client frame comment
|
||||
- Request body: versionId, frameNumber, timestamp, text
|
||||
- Response: created comment
|
||||
- Source: [app/api/client/[token]/comment/route.ts](app/api/client/%5Btoken%5D/comment/route.ts)
|
||||
|
||||
- POST /api/client/{token}/annotation
|
||||
- Purpose: client annotation write
|
||||
- Request body: versionId, frameNumber, drawingData, optional color
|
||||
- Response: annotation
|
||||
- Source: [app/api/client/[token]/annotation/route.ts](app/api/client/%5Btoken%5D/annotation/route.ts)
|
||||
|
||||
- POST /api/client/{token}/approve
|
||||
- Purpose: shot-level approve/changes and legacy version-level approval
|
||||
- Request body:
|
||||
- shot mode: shotId + action
|
||||
- version mode: versionId + status + notes
|
||||
- Response: success
|
||||
- Source: [app/api/client/[token]/approve/route.ts](app/api/client/%5Btoken%5D/approve/route.ts)
|
||||
|
||||
- GET /api/versions/{versionId}/comments
|
||||
- Purpose: internal comments read
|
||||
- Source: [app/api/versions/[versionId]/comments/route.ts](app/api/versions/%5BversionId%5D/comments/route.ts)
|
||||
|
||||
- GET /api/versions/{versionId}/annotations
|
||||
- Purpose: internal annotations read
|
||||
- Source: [app/api/versions/[versionId]/annotations/route.ts](app/api/versions/%5BversionId%5D/annotations/route.ts)
|
||||
|
||||
- GET /api/playlist
|
||||
- Purpose: latest version per shot playlist
|
||||
- Source: [app/api/playlist/route.ts](app/api/playlist/route.ts)
|
||||
|
||||
### 2.3 File uploads
|
||||
|
||||
- POST /api/upload
|
||||
- Purpose: authenticated upload to Hetzner via app server
|
||||
- Body: multipart file (+ type)
|
||||
- Response: url, key
|
||||
- Source: [app/api/upload/route.ts](app/api/upload/route.ts)
|
||||
|
||||
- POST /api/upload/local
|
||||
- Purpose: authenticated video upload path
|
||||
- Body: multipart file
|
||||
- Response: url, key
|
||||
- Source: [app/api/upload/local/route.ts](app/api/upload/local/route.ts)
|
||||
|
||||
- POST /api/upload/presign
|
||||
- Purpose: direct browser->object-storage upload URL
|
||||
- Body: fileName, contentType, optional folder
|
||||
- Response: presignedUrl, key, url
|
||||
- Source: [app/api/upload/presign/route.ts](app/api/upload/presign/route.ts)
|
||||
|
||||
- GET/POST /api/uploadthing
|
||||
- Purpose: UploadThing route handler passthrough (if configured)
|
||||
- Source: [app/api/uploadthing/route.ts](app/api/uploadthing/route.ts)
|
||||
|
||||
- POST /api/batch-upload/presign
|
||||
- Purpose: presign high-res upload key
|
||||
- Body: fileName
|
||||
- Response: presignedUrl, key
|
||||
- Source: [app/api/batch-upload/presign/route.ts](app/api/batch-upload/presign/route.ts)
|
||||
|
||||
- POST /api/batch-upload/preview
|
||||
- Purpose: classify upload actions before upload
|
||||
- Body: projectId, fileNames[]
|
||||
- Response: items[] with statuses (new-version, rename-and-upload, create-task, update-highres, etc)
|
||||
- Source: [app/api/batch-upload/preview/route.ts](app/api/batch-upload/preview/route.ts)
|
||||
|
||||
- POST /api/batch-upload/upload
|
||||
- Purpose: commit highres key or create version records
|
||||
- Body:
|
||||
- update-highres: action, shotId, projectId, key, fileName
|
||||
- version upload: action, shotId, projectId, file, task routing fields
|
||||
- Response: success + action result
|
||||
- Source: [app/api/batch-upload/upload/route.ts](app/api/batch-upload/upload/route.ts)
|
||||
|
||||
### 2.4 EXRs / Rendering / Delivery / Storage / Metadata
|
||||
|
||||
- GET /api/files/{...key}
|
||||
- Purpose: file serving, range requests, local/Hetzner routing
|
||||
- Source: [app/api/files/[...key]/route.ts](app/api/files/%5B...key%5D/route.ts)
|
||||
|
||||
- GET/POST /api/admin/migration
|
||||
- Purpose: local uploads migration status and per-key migration to Hetzner
|
||||
- Source: [app/api/admin/migration/route.ts](app/api/admin/migration/route.ts)
|
||||
|
||||
- POST/DELETE /api/storage-test
|
||||
- Purpose: upload test object / delete test object
|
||||
- Source: [app/api/storage-test/route.ts](app/api/storage-test/route.ts)
|
||||
|
||||
- POST/DELETE /api/shots/{shotId}/highres
|
||||
- Purpose: upload/remove high-res deliverable on shot
|
||||
- Source: [app/api/shots/[shotId]/highres/route.ts](app/api/shots/%5BshotId%5D/highres/route.ts)
|
||||
|
||||
- GET /api/shots/{shotId}/highres/download
|
||||
- Purpose: internal presigned download URL for high-res
|
||||
- Source: [app/api/shots/[shotId]/highres/download/route.ts](app/api/shots/%5BshotId%5D/highres/download/route.ts)
|
||||
|
||||
- GET /api/client/{token}/shots/{shotId}/highres/download
|
||||
- Purpose: client token-gated presigned high-res download URL
|
||||
- Source: [app/api/client/[token]/shots/[shotId]/highres/download/route.ts](app/api/client/%5Btoken%5D/shots/%5BshotId%5D/highres/download/route.ts)
|
||||
|
||||
- GET/POST/DELETE /api/shots/{shotId}/references
|
||||
- Purpose: shot reference image management
|
||||
- Source: [app/api/shots/[shotId]/references/route.ts](app/api/shots/%5BshotId%5D/references/route.ts)
|
||||
|
||||
- GET /api/storyboard/pdf
|
||||
- Purpose: server-rendered printable storyboard HTML with metadata options
|
||||
- Source: [app/api/storyboard/pdf/route.ts](app/api/storyboard/pdf/route.ts)
|
||||
|
||||
Notes:
|
||||
- No dedicated REST endpoint that runs ffmpeg in this repository.
|
||||
- No dedicated REST endpoint that performs EXR rendering on the server.
|
||||
- No dedicated deliveries API namespace.
|
||||
|
||||
---
|
||||
|
||||
## 3. Storage
|
||||
|
||||
Primary storage abstraction: [lib/storage.ts](lib/storage.ts)
|
||||
|
||||
### Provider modes
|
||||
|
||||
- local
|
||||
- uploadthing
|
||||
- s3
|
||||
- r2
|
||||
- b2
|
||||
- minio
|
||||
|
||||
### Dedicated high-res object storage path
|
||||
|
||||
- Hetzner object storage helper methods are used for high-res and presigned direct uploads.
|
||||
- Config source precedence:
|
||||
- SystemConfig table keys
|
||||
- env fallback
|
||||
|
||||
### Folder/key layout in object storage (current code paths)
|
||||
|
||||
- videos/
|
||||
- image/
|
||||
- highres/
|
||||
- storage-test/
|
||||
|
||||
### Local storage
|
||||
|
||||
- LOCAL_UPLOAD_DIR (default ./uploads)
|
||||
- Served via /api/files catch-all route
|
||||
|
||||
### File naming conventions
|
||||
|
||||
- Key format: {folder}/{uuid}-{sanitized-file-name}
|
||||
- Sanitization done by sanitizeFileName to avoid URL/signature problems in object keys
|
||||
|
||||
### EXR locations
|
||||
|
||||
Within web app/runtime:
|
||||
- EXR references are naming metadata (Shot.exrOutput) and file-serving pathing.
|
||||
|
||||
Within DCC tooling docs/scripts:
|
||||
- AE/Nuke workflows target shared export roots and per-shot folders.
|
||||
|
||||
### Preview locations
|
||||
|
||||
- Version media URLs stored in Version.fileUrl
|
||||
- Accessed through app player routes and /api/files routing where applicable
|
||||
|
||||
### Thumbnail locations
|
||||
|
||||
- Shot.thumbnailUrl
|
||||
- Version.thumbnailUrl
|
||||
- Can point to /api/files/{key} or external provider URL
|
||||
|
||||
Relevant sources:
|
||||
- [lib/storage.ts](lib/storage.ts)
|
||||
- [app/api/files/[...key]/route.ts](app/api/files/%5B...key%5D/route.ts)
|
||||
- [actions/settings.ts](actions/settings.ts)
|
||||
|
||||
---
|
||||
|
||||
## 4. Export Pipeline (Current)
|
||||
|
||||
### AE panel
|
||||
|
||||
Documented in repo:
|
||||
- [VFXReviewConnector.md](VFXReviewConnector.md)
|
||||
- [EXT_API_REFERENCE.md](EXT_API_REFERENCE.md)
|
||||
|
||||
Implemented panel script also exists on the host AE installation (outside workspace), and behavior aligns with docs:
|
||||
- Episodes/shots discovery via ext APIs
|
||||
- Shot lookup API usage for metadata
|
||||
- Overlay/slate essential property updates (burn-in style overlays)
|
||||
- Queue EXR / Queue MP4 / Queue MOV render queue actions
|
||||
- Queue EXR (review convention)
|
||||
- Import exported EXR and import renders
|
||||
- Pull picture lock using seq timecodes
|
||||
- Increment shot version by PATCH call
|
||||
- Delivery prep that copies/renames EXR files to delivery folder convention
|
||||
|
||||
### External API endpoints used by DCC tools
|
||||
|
||||
- GET /api/ext/projects/{projectCode}/episodes
|
||||
- GET /api/ext/projects/{projectCode}/shots
|
||||
- GET /api/ext/shots/lookup
|
||||
- PATCH /api/ext/shots/{shotId}
|
||||
|
||||
### Existing render scripts and pipelines
|
||||
|
||||
- AE panel render queue automation (external script)
|
||||
- Nuke connector script creating write-node outputs and shot scripts:
|
||||
- [VFXReviewConnector.py](VFXReviewConnector.py)
|
||||
|
||||
### ffmpeg scripts
|
||||
|
||||
- None found in this repository.
|
||||
|
||||
### Proxy generation
|
||||
|
||||
- Version.proxyUrl field exists in schema.
|
||||
- No implemented proxy-generation worker/function found.
|
||||
|
||||
### Thumbnail generation
|
||||
|
||||
- Upload/assignment flows exist.
|
||||
- No server-side frame-extract thumbnail generator found.
|
||||
|
||||
### Metadata extraction
|
||||
|
||||
- EDL / picture-tracker CSV metadata parsing implemented:
|
||||
- [lib/edl-utils.ts](lib/edl-utils.ts)
|
||||
- [actions/shots.ts](actions/shots.ts)
|
||||
|
||||
### Burn-in generation
|
||||
|
||||
- Achieved via DCC overlay/slate layers and essential properties in AE/Nuke tool workflows.
|
||||
- No ffmpeg burn-in path found.
|
||||
|
||||
---
|
||||
|
||||
## 5. Review System
|
||||
|
||||
### Current review workflow
|
||||
|
||||
Core status derivation:
|
||||
- [lib/shot-status.ts](lib/shot-status.ts)
|
||||
|
||||
Server action orchestration:
|
||||
- [actions/versions.ts](actions/versions.ts)
|
||||
- [actions/approvals.ts](actions/approvals.ts)
|
||||
- [actions/tasks.ts](actions/tasks.ts)
|
||||
- [actions/shots.ts](actions/shots.ts)
|
||||
- [actions/comments.ts](actions/comments.ts)
|
||||
|
||||
### Internal review
|
||||
|
||||
- Version upload:
|
||||
- marks previous versions non-latest
|
||||
- creates new latest version
|
||||
- moves Task to INTERNAL_REVIEW
|
||||
- recalculates Shot.status
|
||||
|
||||
### Client review
|
||||
|
||||
- Tokenized ReviewSession links
|
||||
- Optional password gate with signed cookie unlock
|
||||
- Client comment/annotation/approval endpoints
|
||||
- Share/unshare semantics via shot and version visibility fields
|
||||
|
||||
### Task status flow
|
||||
|
||||
Observed statuses:
|
||||
- TODO
|
||||
- IN_PROGRESS
|
||||
- INTERNAL_REVIEW
|
||||
- CLIENT_REVIEW
|
||||
- CHANGES
|
||||
- DONE
|
||||
|
||||
### Shot status flow
|
||||
|
||||
Derived in priority order:
|
||||
- REVISIONS (any task CHANGES)
|
||||
- COMPLETE (shotApprovalStatus CLIENT_APPROVED)
|
||||
- CLIENT_REVIEW / READY_FOR_CLIENT (internally approved + share flag)
|
||||
- IN_PROGRESS (task TODO/IN_PROGRESS)
|
||||
- INTERNAL_REVIEW (tasks exist)
|
||||
- WAITING (no tasks)
|
||||
|
||||
### Approval process
|
||||
|
||||
- Version-level approvals create Approval rows and update Version.approvalStatus
|
||||
- Shot-level client actions supported via client approve endpoint and shot actions
|
||||
|
||||
Reference doc in repo:
|
||||
- [Shot task workflow.md](Shot%20task%20workflow.md)
|
||||
|
||||
---
|
||||
|
||||
## 6. Authentication
|
||||
|
||||
### External tools
|
||||
|
||||
- /api/ext/* routes authenticate using API_SECRET_KEY via:
|
||||
- Authorization: Bearer <key>
|
||||
- x-api-key header
|
||||
|
||||
### Client review links
|
||||
|
||||
- tokenized route with ReviewSession lookup
|
||||
- optional password hash validation and signed unlock cookie
|
||||
|
||||
### App users
|
||||
|
||||
- NextAuth credentials provider
|
||||
- bcrypt password hash compare
|
||||
- JWT session strategy
|
||||
|
||||
### Middleware behavior
|
||||
|
||||
- Route classes allowed through middleware auth gate:
|
||||
- /api/ext/
|
||||
- /api/client/
|
||||
- /api/display/
|
||||
- /api/files/
|
||||
- /api/uploadthing
|
||||
- Per-route auth still enforced in handlers
|
||||
|
||||
Sources:
|
||||
- [auth.ts](auth.ts)
|
||||
- [auth.config.ts](auth.config.ts)
|
||||
- [middleware.ts](middleware.ts)
|
||||
- [lib/review-auth.ts](lib/review-auth.ts)
|
||||
|
||||
---
|
||||
|
||||
## 7. Existing Background Jobs
|
||||
|
||||
### Cron jobs
|
||||
- None found.
|
||||
|
||||
### Queues
|
||||
- None found.
|
||||
|
||||
### Workers
|
||||
- None found.
|
||||
|
||||
### Polling services
|
||||
- Display devices poll:
|
||||
- /api/display/events
|
||||
- /api/dashboard/stats
|
||||
|
||||
### Docker containers
|
||||
- vfxreview app container
|
||||
- postgres container
|
||||
- Source: [docker-compose.yml](docker-compose.yml)
|
||||
|
||||
### Scheduled tasks
|
||||
- Startup migration commands in container entrypoint:
|
||||
- [entrypoint.sh](entrypoint.sh)
|
||||
- AE panel Prep Delivery launches background PowerShell copy scripts on workstation (outside web app runtime)
|
||||
|
||||
---
|
||||
|
||||
## 8. After Effects Integration (Implemented Features)
|
||||
|
||||
Repo-level references:
|
||||
- [VFXReviewConnector.md](VFXReviewConnector.md)
|
||||
- [EXT_API_REFERENCE.md](EXT_API_REFERENCE.md)
|
||||
|
||||
Implemented capabilities observed/documented:
|
||||
|
||||
- API calls
|
||||
- episodes/shots listing and shot lookup
|
||||
- shot version PATCH
|
||||
- Authentication
|
||||
- bearer API token in script
|
||||
- Comp discovery
|
||||
- shot code extraction from comp names and dropdown selection
|
||||
- Render queue integration
|
||||
- EXR Sequence
|
||||
- REVIEW_PREVIEW (MP4)
|
||||
- 4444 Tri (MOV)
|
||||
- EXR review sequence queue variant
|
||||
- Output path generation
|
||||
- shot/version naming conventions + export root paths
|
||||
- Shot lookup
|
||||
- uses /api/ext/shots/lookup for metadata and naming decisions
|
||||
- Upload features
|
||||
- no direct upload-to-web API flow in panel docs/script (render/output handled in DCC/filesystem)
|
||||
- Burn-in generation
|
||||
- overlay and slate essential properties
|
||||
- Review integration
|
||||
- preview comp build and version increment sync
|
||||
|
||||
---
|
||||
|
||||
## 9. Configuration
|
||||
|
||||
### Environment variables
|
||||
|
||||
Primary reference: [.env.example](.env.example)
|
||||
|
||||
Observed vars in code/docs include:
|
||||
|
||||
- DATABASE_URL
|
||||
- NEXTAUTH_SECRET
|
||||
- NEXTAUTH_URL
|
||||
- NEXT_PUBLIC_APP_URL
|
||||
- NEXT_PUBLIC_APP_NAME
|
||||
- API_SECRET_KEY
|
||||
- AUTH_SECRET
|
||||
- STORAGE_PROVIDER
|
||||
- LOCAL_UPLOAD_DIR
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
- AWS_REGION
|
||||
- AWS_BUCKET_NAME
|
||||
- R2_ACCESS_KEY_ID
|
||||
- R2_SECRET_ACCESS_KEY
|
||||
- R2_ACCOUNT_ID
|
||||
- R2_BUCKET_NAME
|
||||
- R2_PUBLIC_URL
|
||||
- B2_APPLICATION_KEY_ID
|
||||
- B2_APPLICATION_KEY
|
||||
- B2_BUCKET_NAME
|
||||
- B2_ENDPOINT
|
||||
- MINIO_ENDPOINT
|
||||
- MINIO_ACCESS_KEY
|
||||
- MINIO_SECRET_KEY
|
||||
- MINIO_BUCKET_NAME
|
||||
- HETZNER_ENDPOINT
|
||||
- HETZNER_ACCESS_KEY
|
||||
- HETZNER_SECRET_KEY
|
||||
- HETZNER_BUCKET_NAME
|
||||
- UPLOADTHING_SECRET
|
||||
- UPLOADTHING_APP_ID
|
||||
- EMAIL_FROM
|
||||
- EMAIL_SERVER_HOST
|
||||
- EMAIL_SERVER_PORT
|
||||
- EMAIL_SERVER_USER
|
||||
- EMAIL_SERVER_PASSWORD
|
||||
- SLACK_DEFAULT_WEBHOOK
|
||||
|
||||
### Storage configuration
|
||||
|
||||
- Provider abstraction in [lib/storage.ts](lib/storage.ts)
|
||||
- Hetzner overrides in [actions/settings.ts](actions/settings.ts) and SystemConfig table
|
||||
|
||||
### Render configuration
|
||||
|
||||
- No server-side renderer configuration module found.
|
||||
- DCC render templates are documented in [VFXReviewConnector.md](VFXReviewConnector.md).
|
||||
|
||||
### ffmpeg configuration
|
||||
|
||||
- None found in repository.
|
||||
|
||||
### Object storage configuration
|
||||
|
||||
- Implemented for AWS S3/R2/B2/MinIO + dedicated Hetzner helper path
|
||||
|
||||
---
|
||||
|
||||
## 10. Existing Utility Functions (Reusable)
|
||||
|
||||
### Metadata extraction
|
||||
|
||||
- parseEdlCsv
|
||||
- parsePictureTrackerCsv
|
||||
- Source: [lib/edl-utils.ts](lib/edl-utils.ts)
|
||||
|
||||
### File scanning
|
||||
|
||||
- Local upload tree walker in migration route
|
||||
- Nuke connector plate/render directory scanners
|
||||
- Sources:
|
||||
- [app/api/admin/migration/route.ts](app/api/admin/migration/route.ts)
|
||||
- [VFXReviewConnector.py](VFXReviewConnector.py)
|
||||
|
||||
### EXR sequence detection
|
||||
|
||||
- Nuke connector helper sequence detectors and pattern conversion
|
||||
- Source: [VFXReviewConnector.py](VFXReviewConnector.py)
|
||||
|
||||
### MOV generation
|
||||
|
||||
- No server-side MOV generation utility found.
|
||||
- MOV outputs are DCC render queue outputs in external scripts/docs.
|
||||
|
||||
### Checksums
|
||||
|
||||
- No checksum utility found.
|
||||
|
||||
### Frame counting
|
||||
|
||||
- durationToFrameCount and frame math helpers
|
||||
- Source: [lib/frame-utils.ts](lib/frame-utils.ts)
|
||||
|
||||
### Timecode extraction/conversion
|
||||
|
||||
- frameToTimecode / formatTimecode
|
||||
- CSV timecode validation/parsing
|
||||
- Sources:
|
||||
- [lib/frame-utils.ts](lib/frame-utils.ts)
|
||||
- [lib/utils.ts](lib/utils.ts)
|
||||
- [lib/edl-utils.ts](lib/edl-utils.ts)
|
||||
|
||||
---
|
||||
|
||||
## 11. High-Level Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[AE Panel / Nuke Connector\nExternal DCC tools] -->|API key auth| B[Next.js App Router APIs]
|
||||
B --> C[Prisma ORM]
|
||||
C --> D[(PostgreSQL)]
|
||||
|
||||
B --> E[Storage Abstraction]
|
||||
E --> F[(Hetzner Object Storage)]
|
||||
E --> G[(S3/R2/B2/MinIO)]
|
||||
B --> H[(Local uploads dir)]
|
||||
|
||||
I[Internal reviewers\nNextAuth session] --> B
|
||||
J[Client reviewers\nToken review sessions] --> B
|
||||
|
||||
K[ESP32 display client] -->|x-display-key polling| B
|
||||
|
||||
A --> L[Shared filesystem render roots\nEXR/MP4/MOV outputs]
|
||||
L -->|served/linked via app metadata| B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Files Inspected (Primary)
|
||||
|
||||
- [prisma/schema.prisma](prisma/schema.prisma)
|
||||
- [lib/storage.ts](lib/storage.ts)
|
||||
- [lib/shot-status.ts](lib/shot-status.ts)
|
||||
- [lib/review-auth.ts](lib/review-auth.ts)
|
||||
- [lib/edl-utils.ts](lib/edl-utils.ts)
|
||||
- [lib/frame-utils.ts](lib/frame-utils.ts)
|
||||
- [lib/utils.ts](lib/utils.ts)
|
||||
- [auth.ts](auth.ts)
|
||||
- [auth.config.ts](auth.config.ts)
|
||||
- [middleware.ts](middleware.ts)
|
||||
- [next.config.ts](next.config.ts)
|
||||
- [.env.example](.env.example)
|
||||
- [docker-compose.yml](docker-compose.yml)
|
||||
- [Dockerfile](Dockerfile)
|
||||
- [entrypoint.sh](entrypoint.sh)
|
||||
- [EXT_API_REFERENCE.md](EXT_API_REFERENCE.md)
|
||||
- [VFXReviewConnector.md](VFXReviewConnector.md)
|
||||
- [VFXReviewConnector.py](VFXReviewConnector.py)
|
||||
- [# VFXReview Connector for Nuke.md](#%20VFXReview%20Connector%20for%20Nuke.md)
|
||||
- API handlers under [app/api](app/api)
|
||||
- Server actions under [actions](actions)
|
||||
|
||||
---
|
||||
|
||||
End of current-state report.
|
||||
File diff suppressed because it is too large
Load Diff
+357
-24
@@ -6,7 +6,161 @@ A dockable ScriptUI panel for Adobe After Effects 2024+ that integrates with the
|
||||
|
||||
## Configuration
|
||||
|
||||
At the top of the script, four global variables control how the panel connects to your project:
|
||||
Settings are stored per-machine via the **⚙ Settings** button in the panel (persisted in After Effects' built-in preferences — no need to edit the JSX file).
|
||||
|
||||
| Setting | Purpose |
|
||||
|---|---|
|
||||
| **Server URL** | Base URL of the VFXReview server (e.g. `https://review.twotalesvfx.com`) |
|
||||
| **API Token** | Bearer token matching `API_SECRET_KEY` in the server `.env` |
|
||||
| **Export Root** | Local path where renders and deliverables are written |
|
||||
| **Footage Root** | Local path where EXR plate folders are read from |
|
||||
| **Banner Path** | Path to the panel banner image |
|
||||
| **Artist Email** | Used for render attribution in the pipeline |
|
||||
| **Handle Frames** | Plate handle frames prepended before the shot's source TC in-point (default: 8) |
|
||||
| **Path Mappings** | *Optional.* Rewrites paths the **server** supplies — see below |
|
||||
|
||||
### Server paths vs. this machine
|
||||
|
||||
The server addresses render folders from its own root (`render.outputRoot`,
|
||||
e.g. `//SAN`), which a workstation may not resolve — the render worker
|
||||
translates them with its own `pathMappings` because it runs as a service
|
||||
account. The panel needs the same translation to open a render for QC.
|
||||
|
||||
It resolves this itself in most cases, trying in order:
|
||||
|
||||
1. **Path Mappings**, if configured — format `//SAN/=V:/_EXPORTS/`, several
|
||||
separated by `;`.
|
||||
2. **The server path as given** — correct when `render.outputRoot` is already
|
||||
a path this machine can reach.
|
||||
3. **Re-based on Export Root** — the server's export layout mirrors the panel's
|
||||
from the show folder down (`{root}/{showId}/{episode}/{shot}/{version}`), so
|
||||
the tail is simply re-attached to Export Root.
|
||||
|
||||
Step 3 is why this usually needs no configuration. **Diagnostics** checks the
|
||||
whole thing against a real render folder from the QC queue and names every path
|
||||
it tried when it fails.
|
||||
|
||||
### More than one on-disk convention
|
||||
|
||||
Every candidate is tested for existence and the first that resolves wins, so a
|
||||
studio whose output root changed at some point can map the **same prefix more
|
||||
than once** and let the panel work out which applies per export:
|
||||
|
||||
```
|
||||
//SAN/=V:/_EXPORTS/;//SAN/=V:/_EXPORTS/UNG/
|
||||
```
|
||||
|
||||
Older exports keep whatever path was stored on them when they were queued —
|
||||
changing `render.outputRoot` on the server only affects new ones — so mixed
|
||||
conventions in one database are normal and permanent. Add a mapping per
|
||||
convention rather than trying to make one rewrite cover them all.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Folder Structure
|
||||
|
||||
### Export Root
|
||||
|
||||
All outputs from the connector are written under **`<Export Root>/{SHOW_ID}/`** (e.g. `V:/_EXPORTS`). The show ID is appended automatically from the selected project — configure `exportRoot` as the bare root without a show ID.
|
||||
|
||||
```
|
||||
<Export Root>/
|
||||
└── {SHOW_ID}/ # e.g. UNG/
|
||||
├── {SHOT_CODE}/ # e.g. UNG_108_001_010/
|
||||
│ ├── {shot}_cmp_TT_{ver}.[#####].exr # Queue EXR / Queue EXR (Review)
|
||||
│ └── {shot}_cmp_TT_{ver}.mp4 / .mov # Queue MP4 / Queue MOV
|
||||
│
|
||||
├── DELIVERY/
|
||||
│ └── {EPISODE_OR_SCENE}/ # e.g. UNG_108/
|
||||
│ └── {YYYYMMDD}_Delivery/
|
||||
│ └── {SHOT_CODE}/
|
||||
│ └── {shot}_cmp_TT_{ver}.[#####].exr
|
||||
│
|
||||
└── (render farm outputs — managed by the server)
|
||||
└── {PROJECT_CODE}/{EPISODE}/{SHOT_CODE}/{ver}/
|
||||
└── {shot}_cmp_TT_{ver}.[#####].exr
|
||||
```
|
||||
|
||||
**Version naming:** `{SHOT_CODE}_cmp_TT_{version}` — e.g. `UNG_108_001_010_cmp_TT_v003.[01001].exr`
|
||||
|
||||
Versions are set manually via **Increment Shot Version** or the shot detail page. Queuing a render does **not** auto-increment the version.
|
||||
|
||||
---
|
||||
|
||||
### Footage Root
|
||||
|
||||
Source EXR plates are read from **`<Footage Root>/{SHOW_ID}/`**. Configure `footageRoot` as the bare root (e.g. `V:/_FOOTAGE`).
|
||||
|
||||
```
|
||||
<Footage Root>/
|
||||
└── {SHOW_ID}/ # e.g. UNG/ or DH2_VFX/
|
||||
├── {EPISODE_CODE}/ # episodic only: e.g. UNG_108/
|
||||
│ └── {SHOT_CODE}/ # standard layout: plates grouped per shot
|
||||
│ └── {SHOT_CODE}_{ELEM}_{VER}/
|
||||
│ └── *.exr
|
||||
│
|
||||
└── _PICLOCKS/
|
||||
└── {EPISODE_CODE}_*.mov # matched by episode prefix
|
||||
```
|
||||
|
||||
### Frame rate
|
||||
|
||||
Comps are built at the **shot record's `fps`**, and plates are conformed to it.
|
||||
That value is the show's delivery rate and the authority; a plate's own rate is
|
||||
whatever happens to be on disk.
|
||||
|
||||
`fps` defaults to 24 when a shot is created, so **check it after importing a
|
||||
show that is not 24** — every shot carries its own value, and a wrong one
|
||||
produces comps at the wrong rate, plates conformed to the wrong speed, and
|
||||
timecode-to-frame sums that are quietly off.
|
||||
|
||||
A plate at a whole-number multiple of the show rate (50 fps on a 25 fps show) is
|
||||
a deliberate over-crank and is conformed as intended. Validation reports the
|
||||
difference either way, saying which of the two it looks like.
|
||||
|
||||
**Scenes are not folders.** Standard projects have no level between the show
|
||||
folder and the shot folder — the scene picker filters the shot list only.
|
||||
|
||||
**Two layouts are supported, and neither is migrated.** The connector prefers the
|
||||
grouped layout above; where a `{SHOT_CODE}/` folder does not exist it falls back
|
||||
to scanning the parent for folders named `{SHOT_CODE}_*` (the original UNG
|
||||
layout, where plates sit directly in the episode folder). All matching folders
|
||||
are imported as separate footage layers. If a `{SHOT_CODE}/` folder exists but
|
||||
holds no plate subfolders, that is reported as no footage — the fallback scan is
|
||||
not attempted, so stray plate folders sitting beside the shot cannot be picked up
|
||||
as its footage. Underscore-prefixed folders (`_ARCHIVE`, `_GFX`, `_PULLS`, …) are
|
||||
never treated as shots or plates.
|
||||
|
||||
---
|
||||
|
||||
### Episodic vs. Standard productions
|
||||
|
||||
| | Episodic | Standard |
|
||||
|---|---|---|
|
||||
| Shot code format | `{SHOW}_{EP}_{SCENE}_{NUM}` | `{SHOW}_{SCENE}_{NUM}` |
|
||||
| Shot Builder | Episode dropdown → Shot dropdown | Scene dropdown → Shot dropdown |
|
||||
| Footage path | `{footageRoot}/{showId}/{EPISODE}/` | `{footageRoot}/{showId}/{SCENE}/` |
|
||||
| Export path | `{exportRoot}/{showId}/{SHOT_CODE}/` | same |
|
||||
| Delivery subfolder | `DELIVERY/{EPISODE}/…` | `DELIVERY/{SCENE}/…` |
|
||||
|
||||
---
|
||||
|
||||
## Migrating an existing project (e.g. UNG)
|
||||
|
||||
If you previously set `exportRoot = V:/_EXPORTS/UNG`, update it to `V:/_EXPORTS`. The code now appends `/{showId}/` automatically, so `V:/_EXPORTS/UNG/…` is still produced — no files need to move.
|
||||
|
||||
Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`.
|
||||
|
||||
---
|
||||
|
||||
## New production checklist
|
||||
|
||||
1. Create `<Footage Root>/{SHOW_ID}/[{EPISODE_CODE}/]{SHOT_CODE}/` — episode level for episodic shows only, never a scene level — and place each plate in its own `{SHOT_CODE}_{ELEM}_{VER}/` folder inside it.
|
||||
2. `<Export Root>/{SHOW_ID}/` is created automatically on first render.
|
||||
3. Add the project to the VFXReview server with the correct **Show ID** set.
|
||||
4. Open the panel in After Effects, click **⚙**, enter the Server URL, API Token, bare `exportRoot`, and bare `footageRoot`, then click **Save**.
|
||||
5. The **Project** dropdown will populate automatically. Select the production and the episode/scene list will load.
|
||||
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
@@ -21,13 +175,15 @@ At the top of the script, four global variables control how the panel connects t
|
||||
|
||||
## Panel Layout
|
||||
|
||||
The panel is divided into five sections:
|
||||
The panel is divided into seven sections:
|
||||
|
||||
1. **Shot Builder** — Episode/shot selection and comp creation
|
||||
2. **Actions** — Per-shot and batch operations
|
||||
3. **Workspace** — One-time project initialisation
|
||||
4. **Color Space** — OCIO effect management
|
||||
5. **Status** — Live feedback on selected comps and last action
|
||||
2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews
|
||||
3. **QC** — Technical QC queue for rendered exports
|
||||
4. **Actions** — Per-shot and batch operations
|
||||
5. **Workspace** — One-time project initialisation
|
||||
6. **Color Space** — OCIO effect management
|
||||
7. **Status** — Live feedback, plus Diagnostics and the last report
|
||||
|
||||
---
|
||||
|
||||
@@ -45,12 +201,78 @@ Constructs a full shot composite in the open AE project from the episode and sho
|
||||
**Process:**
|
||||
1. Calls the shot lookup API to retrieve metadata (description, notes, episode, scene, etc.).
|
||||
2. If a comp with the shot code already exists, updates its overlay instead of rebuilding.
|
||||
3. Scans `FOOTAGE_ROOT/{episodeCode}/` for subfolders matching `{shotCode}_*` and imports each as an EXR image sequence at 24 fps.
|
||||
3. Scans `FOOTAGE_ROOT/{episodeCode}/` for subfolders matching `{shotCode}_*` and imports each as an EXR image sequence, conformed to the shot's frame rate.
|
||||
4. Creates a `{shotCode}_FOOTAGE` precomp containing all imported sequences, each with an OCIO Color Space Transform effect added (disabled by default).
|
||||
5. Creates the main `{shotCode}` comp and places the footage precomp inside it.
|
||||
6. If a `_SHOW LUT` comp exists in the project, adds it as a collapsed layer on top.
|
||||
7. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp.
|
||||
8. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders.
|
||||
6. Anchors both comps on the plate's start timecode — see [Start timecode](#start-timecode).
|
||||
7. If a `_SHOW LUT` comp exists in the project, adds it as a collapsed layer on top.
|
||||
8. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp.
|
||||
9. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders.
|
||||
|
||||
If any step fails, the status line names the stage and a copyable report opens —
|
||||
see [Diagnostics and logging](#diagnostics-and-logging).
|
||||
|
||||
#### Start timecode
|
||||
|
||||
Neither comp starts at `00:00:00:00`. Both are anchored on the plate's own start
|
||||
timecode, taken from the first source that has one:
|
||||
|
||||
| Rank | Source | Notes |
|
||||
|---|---|---|
|
||||
| 1 | The first EXR's header | The `timeCode` attribute, or `timeCodeString` where the packed one is absent. Read straight off disk — no pixel decode, and no dependency on what AE chose to expose. |
|
||||
| 2 | AE's footage metadata | What "New Comp from Selection" would have inherited. |
|
||||
| 3 | API `timecodeStart` less `handleFrames` | The convention **Set Start Timecode** uses. Last resort: the API stores the *editorial* in-point, which is a handle-length ahead of the plate by design. |
|
||||
|
||||
This is the same ranking the render worker's validation uses (RenderPipeline2
|
||||
§8.1a), so a comp is anchored on exactly the value its EXRs are later checked
|
||||
against — a shot built from rank 3 and validated against rank 1 is how a
|
||||
timecode desync gets into a delivery.
|
||||
|
||||
The status line and the diagnostics log both name the rank that was used, e.g.
|
||||
`start frame 864108 from EXR timecode 10:00:04:12`. A plate carrying no timecode
|
||||
in any rank leaves the comp at `00:00:00:00` and says so.
|
||||
|
||||
---
|
||||
|
||||
## Render Pipeline
|
||||
|
||||
### Queue Export
|
||||
|
||||
Saves the project, builds the render manifest from the selected comp and the
|
||||
shot record, and submits it to the farm.
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| **Urgent** | Priority 20 — claimable outside the render window, ahead of normal jobs |
|
||||
| **No preview** | Renders the EXRs but builds no preview, inheriting the previous export's MOV, review MP4 and thumbnail |
|
||||
|
||||
**No preview** exists for deliverables framed differently from the review —
|
||||
unscaled or un-punched-in EXRs. A preview rebuilt from those frames would be
|
||||
the wrong picture, while the earlier export's MOV is already correct, so it
|
||||
carries over instead. The export still goes through QC, and the delivery
|
||||
package still gets a MOV.
|
||||
|
||||
Leave it unticked for an ordinary submission: the preview is what QC and the
|
||||
client review see.
|
||||
|
||||
---
|
||||
|
||||
## QC
|
||||
|
||||
Technical QC on the **rendered EXRs**, not the H.264 preview — the point is
|
||||
catching mask slips, export glitches and metadata faults at full resolution.
|
||||
QC is per-export: a new export always needs new QC.
|
||||
|
||||
| Control | Behaviour |
|
||||
|---|---|
|
||||
| **Load QC Queue** | Lists every export in `READY_FOR_QC` for the selected project, oldest first. Entries flagged with a warning count had a non-blocking validation warning worth a closer look. |
|
||||
| **Open Render** (or double-click) | Imports that export's actual EXR sequence into a `QC_{shot}_{version}` comp at full res and opens it. Re-selecting an already-imported shot just reopens the comp. |
|
||||
| **Notes** | Sent with the decision. Optional on pass; a fail with no notes asks for confirmation, because the artist only sees what you write here. |
|
||||
| **QC Pass** | Marks the version **ready for delivery** and nothing else — nothing is shared to the client, no approval state changes, no task moves. |
|
||||
| **QC Fail** | Sets the export to `QC_FAILED` and the comp task to **CHANGES**. The artist fixes and queues a new version; the old export is superseded. |
|
||||
|
||||
Passing an export is what makes it eligible for a delivery package on the web
|
||||
app's **Pipeline → Deliveries** page.
|
||||
|
||||
---
|
||||
|
||||
@@ -64,7 +286,7 @@ Updates the **Status** panel to show the current count of selected `CompItem`s.
|
||||
### Fix Comp Names
|
||||
Renames selected comps so their names match the shot code extracted from their current name.
|
||||
|
||||
- Shot codes follow the pattern `XXX_000_000_000` (e.g. `UNG_106_010_020`).
|
||||
- Shot codes are read by anchoring on the selected project's showId — see [Shot Code Convention](#shot-code-convention).
|
||||
- If a comp name contains a valid shot code but has extra text, it is trimmed to the code only.
|
||||
- Wrapped in an undo group.
|
||||
|
||||
@@ -160,7 +382,7 @@ Imports a picture-lock (offline cut) video file and trims it to the correct sequ
|
||||
|
||||
**Process:**
|
||||
1. Calls the API to retrieve `seqTimecodeStart` and `seqTimecodeEnd` for the shot.
|
||||
2. Searches the `_PICLOCKS` project folder for already-imported footage matching the episode prefix (first 7 characters of the shot code, e.g. `UNG_106`).
|
||||
2. Searches the `_PICLOCKS` project folder for already-imported footage matching the episode prefix — `{showId}_{episode}` on episodic shows (e.g. `UNG_106`), plain `{showId}` on standard ones.
|
||||
3. If not found, scans `FOOTAGE_ROOT/_PICLOCKS/` for a file matching `{episodePrefix}_*` and imports it.
|
||||
4. Calculates the in/out offsets from the sequence timecodes relative to the piclock's start (assumed `01:00:00:00` / 3590 s unless embedded timecode is available), with **±8 frame handles**.
|
||||
5. Adds the piclock footage as a layer at the top of the comp, scales it to fill the frame, and sets `startTime`, `inPoint`, and `outPoint` to isolate the correct range.
|
||||
@@ -172,17 +394,37 @@ Imports a picture-lock (offline cut) video file and trims it to the correct sequ
|
||||
|
||||
## Workspace
|
||||
|
||||
### Per-project templates
|
||||
|
||||
Each show has its own template AEP — its overlay, slate/export template and,
|
||||
most importantly, its **show LUT**. These are configured per project on the web
|
||||
app under **Project → Settings → Pipeline**, not in the panel, because the
|
||||
render farm's headless preview build reads the same values: if the two
|
||||
disagreed, a farm preview would be quietly the wrong colour.
|
||||
|
||||
| Setting | Purpose |
|
||||
|---|---|
|
||||
| **Template project (.aep)** | Imported by Initialise Workspace and by every headless preview build |
|
||||
| **Renders root** | Where per-shot pre-renders live; the panel appends the shot code (Import Renders) |
|
||||
| **Overlay / Slate / LUT comp** | Only needed when a template departs from the naming convention |
|
||||
|
||||
Comp names default to a convention derived from the show ID:
|
||||
`{showId}_VFX_OVERLAY`, `{showId}_EXPORT_TEMPLATE`, `_SHOW LUT`. A show whose
|
||||
template follows the pattern needs no comp configuration at all — leave the
|
||||
fields blank and they follow the convention, including any later change to it.
|
||||
|
||||
### Initialise Workspace
|
||||
One-time setup that creates the standard project folder hierarchy, imports the slate/overlay template AEP, and configures color settings.
|
||||
|
||||
Works for any project: the episode folders and the template both come from the
|
||||
selected project rather than being fixed to one show.
|
||||
|
||||
**Folder structure created:**
|
||||
|
||||
```
|
||||
__SHOTS/
|
||||
UNG_101/
|
||||
UNG_102/
|
||||
…
|
||||
UNG_117/
|
||||
{EPISODE_CODE}/ ← one per episode, from the project's episode list
|
||||
… (episodic shows only; standard shows get none)
|
||||
_PREVIEWS/
|
||||
_PRECOMPS/
|
||||
_PICLOCKS/
|
||||
@@ -192,8 +434,15 @@ _RENDERS/
|
||||
_UTILITIES/
|
||||
```
|
||||
|
||||
Episodes come from `GET /api/ext/projects/{code}/episodes`. If a show has none
|
||||
entered yet, the folders found under its footage root are used instead, so the
|
||||
button still does something useful on day one.
|
||||
|
||||
**Template import:**
|
||||
Imports `X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep` as a project merge.
|
||||
Imports the project's **Template project (.aep)** as a project merge — see
|
||||
[Per-project templates](#per-project-templates). If none is configured the
|
||||
folders and colour settings are still applied, and the status line says the
|
||||
template was not set.
|
||||
|
||||
**Color settings (applied outside the undo group):**
|
||||
- Sets project bit depth to **32-bit**.
|
||||
@@ -246,15 +495,19 @@ Accept: application/json
|
||||
|
||||
## Shot Code Convention
|
||||
|
||||
Shot codes follow the pattern `AAA_NNN_NNN_NNN` where:
|
||||
- `AAA` = three-letter show prefix (e.g. `UNG`)
|
||||
- First `NNN` = episode number (e.g. `106`)
|
||||
- Second `NNN` = sequence number (e.g. `010`)
|
||||
- Third `NNN` = shot number (e.g. `020`)
|
||||
Shot codes are the project's **showId** followed by three-digit tokens:
|
||||
|
||||
Example: `UNG_106_010_020`
|
||||
| Project type | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Episodic | `{showId}_{episode}_{scene}_{shot}` | `UNG_106_010_020` (showId `UNG`) |
|
||||
| Standard | `{showId}_{scene}_{shot}` | `DH2_VFX_027_010` (showId `DH2_VFX`) |
|
||||
|
||||
Episode codes follow the pattern `AAA_NNN` (e.g. `UNG_106`).
|
||||
The showId is **not** always three letters and may itself contain underscores, so
|
||||
codes are parsed by stripping the known showId prefix — never by counting
|
||||
underscores or matching a fixed `AAA_` pattern. Everything after the showId is
|
||||
three digits per token.
|
||||
|
||||
Episode codes follow `{showId}_{episode}` (e.g. `UNG_106`).
|
||||
|
||||
---
|
||||
|
||||
@@ -296,3 +549,83 @@ The **Status** panel at the bottom of the UI shows two lines:
|
||||
- **Line 2** — Result or progress message from the last operation.
|
||||
|
||||
Both lines are updated throughout batch operations so progress is visible in real time.
|
||||
|
||||
Below them are two buttons:
|
||||
|
||||
- **Diagnostics** — runs the environment self-test described below.
|
||||
- **Show Last Report** — re-opens the trace from the last button pressed.
|
||||
|
||||
---
|
||||
|
||||
## Versions
|
||||
|
||||
The panel's version is the `PANEL_VERSION` constant at the top of
|
||||
`VFXReviewConnector.jsx`. It appears in the panel title, in every diagnostics
|
||||
report, and is sent to the server when the panel loads.
|
||||
|
||||
**Pipeline → Machines → Deployed tool versions** then shows which version each
|
||||
workstation is running, alongside the render workers. Anything behind the newest
|
||||
version seen for that tool is flagged `→ rebuild to vX` — so updating one
|
||||
machine is what surfaces the ones still to do. No expected-version list to keep
|
||||
in sync; the newest thing reporting in is the benchmark.
|
||||
|
||||
**Bump `PANEL_VERSION` whenever you copy the .jsx to a workstation.** If two
|
||||
machines report the same version but behave differently, one of them was copied
|
||||
without the bump.
|
||||
|
||||
---
|
||||
|
||||
## Diagnostics and logging
|
||||
|
||||
Every button press is traced: what it tried, in what order, with the paths and
|
||||
HTTP codes it was working with. The trace goes to an in-memory buffer and to a
|
||||
log file, and it is shown automatically when something fails.
|
||||
|
||||
**Log file:** `%APPDATA%/VFXReviewConnector/connector.log` (next to `config.json`).
|
||||
It is truncated once it passes 1 MB, so it can always be pasted into a message.
|
||||
The API token is never written to it — only whether one is set.
|
||||
|
||||
### When Build Shot fails
|
||||
|
||||
The status line now names the stage it failed at, and a scrollable report opens
|
||||
with the whole trace. The stages, and what each one means:
|
||||
|
||||
| Stage | Meaning | Usual fix |
|
||||
|---|---|---|
|
||||
| `project` | No project selected in the panel | The project dropdown is empty because the server URL or token is wrong |
|
||||
| `shot lookup` | The server returned no shot record | Report shows the HTTP code: `0` = server unreachable, `401/403` = bad token, `404` = shot not in that project |
|
||||
| `configuration` | Footage Root is blank in Settings | Settings are **per-machine**; a fresh workstation starts blank |
|
||||
| `footage folder` | The show/episode folder does not exist here | Report names the deepest folder that *does* exist — usually an unmapped drive or a different drive letter/UNC on that machine |
|
||||
| `plate folders` | Folder exists, no plates for that shot code | Report lists the folders that *are* there, so a naming mismatch is visible |
|
||||
| `plate import` | Plate folders matched but held no importable EXRs | Report lists each folder's contents (`n .exr, n other files`) |
|
||||
| `comp creation` | After Effects threw while building the comps | Not a path or server problem — the AE error text is in the report |
|
||||
| `unexpected error` | Anything thrown that was not anticipated | Report carries the error text, line number and stack |
|
||||
|
||||
Non-fatal conditions are traced too rather than passing silently: a missing
|
||||
`_SHOW LUT`, an overlay that could not be applied, a missing `__SHOTS/{group}`
|
||||
folder.
|
||||
|
||||
### Diagnostics button
|
||||
|
||||
Checks everything Build Shot depends on **without building anything**, so a
|
||||
workstation can be cleared before an artist is blocked. It verifies:
|
||||
|
||||
1. `curl.exe` is present and runnable (every server call goes through it).
|
||||
2. Server URL and API token are set, and `GET /api/ext/projects` succeeds.
|
||||
3. Footage Root resolves to a folder that exists, and lists the plate folders
|
||||
found for the currently selected shot.
|
||||
4. Export Root resolves to a folder that exists.
|
||||
5. The shot lookup succeeds for the currently selected shot.
|
||||
6. The open AE project has `UNG_VFX_OVERLAY`, `UNG_EXPORT_TEMPLATE`,
|
||||
`_SHOW LUT`, and the `__SHOTS` / `_FOOTAGE_4K` / `_PRECOMPS` / `_PREVIEWS`
|
||||
folders.
|
||||
|
||||
Each failure is reported as a `PROBLEM` line with the offending path or HTTP
|
||||
response, and the report ends with a problem count.
|
||||
|
||||
### Errors that used to be invisible
|
||||
|
||||
ScriptUI silently swallows exceptions thrown inside a button handler, so a hard
|
||||
failure looked like a button that did nothing at all. Every handler is now
|
||||
wrapped: anything thrown is caught, logged with its line number and stack, shown
|
||||
in the status line, and surfaced in the report dialog.
|
||||
|
||||
+82
-72
@@ -5,7 +5,7 @@ import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ApprovalStatus } from "@prisma/client";
|
||||
import { recalcShotStatus } from "@/lib/shot-status";
|
||||
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
|
||||
import { notifyApprovalChange } from "@/lib/notifications";
|
||||
import { slackNotifyApproval } from "@/lib/slack";
|
||||
import { versionLabel } from "@/lib/utils";
|
||||
@@ -29,57 +29,62 @@ export async function submitApproval(data: z.infer<typeof approvalSchema>) {
|
||||
throw new Error("Insufficient permissions to review versions");
|
||||
}
|
||||
|
||||
// Create the approval record
|
||||
const approval = await db.approval.create({
|
||||
data: {
|
||||
versionId: parsed.versionId,
|
||||
userId: session.user.id,
|
||||
status: parsed.status,
|
||||
notes: parsed.notes,
|
||||
},
|
||||
});
|
||||
// The verdict, the version, the task and the derived shot status are one
|
||||
// decision and are written as one: a failure partway through used to leave the
|
||||
// three layers disagreeing, with no way to tell which one was right.
|
||||
const { approval, version } = await db.$transaction(async (tx) => {
|
||||
const approval = await tx.approval.create({
|
||||
data: {
|
||||
versionId: parsed.versionId,
|
||||
userId: session.user.id,
|
||||
status: parsed.status,
|
||||
notes: parsed.notes,
|
||||
},
|
||||
});
|
||||
|
||||
// Update version approval status
|
||||
await db.version.update({
|
||||
where: { id: parsed.versionId },
|
||||
data: { approvalStatus: parsed.status },
|
||||
});
|
||||
await tx.version.update({
|
||||
where: { id: parsed.versionId },
|
||||
data: { approvalStatus: parsed.status },
|
||||
});
|
||||
|
||||
// Load version + task + shot + project for downstream effects
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: parsed.versionId },
|
||||
include: {
|
||||
task: {
|
||||
include: {
|
||||
shot: true,
|
||||
project: true,
|
||||
// Load version + task + shot + project for downstream effects
|
||||
const version = await tx.version.findUnique({
|
||||
where: { id: parsed.versionId },
|
||||
include: {
|
||||
task: {
|
||||
include: {
|
||||
shot: true,
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!version) throw new Error("Version not found");
|
||||
|
||||
// Update task status based on approval result
|
||||
if (version.task && parsed.status !== "PENDING_REVIEW") {
|
||||
if (parsed.status === "APPROVED") {
|
||||
await tx.task.update({
|
||||
where: { id: version.task.id },
|
||||
data: { status: "DONE" },
|
||||
});
|
||||
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
|
||||
await tx.task.update({
|
||||
where: { id: version.task.id },
|
||||
data: { status: "CHANGES" },
|
||||
});
|
||||
}
|
||||
|
||||
// Recalculate shot status from updated task states
|
||||
if (version.task.shot) {
|
||||
await recalcShotStatus(version.task.shot.id, tx);
|
||||
}
|
||||
}
|
||||
|
||||
return { approval, version };
|
||||
});
|
||||
|
||||
if (!version) throw new Error("Version not found");
|
||||
|
||||
// Update task status based on approval result
|
||||
if (version.task && parsed.status !== "PENDING_REVIEW") {
|
||||
if (parsed.status === "APPROVED") {
|
||||
await db.task.update({
|
||||
where: { id: version.task.id },
|
||||
data: { status: "DONE" },
|
||||
});
|
||||
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
|
||||
await db.task.update({
|
||||
where: { id: version.task.id },
|
||||
data: { status: "CHANGES" },
|
||||
});
|
||||
}
|
||||
|
||||
// Recalculate shot status from updated task states
|
||||
if (version.task.shot) {
|
||||
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications
|
||||
const reviewer = await db.user.findUnique({ where: { id: session.user.id } });
|
||||
const reviewerName = reviewer?.name ?? "Reviewer";
|
||||
@@ -166,34 +171,39 @@ export async function unapproveVersion(versionId: string) {
|
||||
throw new Error("Only approved versions can be unapproved");
|
||||
}
|
||||
|
||||
// Create an audit record for the unapproval
|
||||
await db.approval.create({
|
||||
data: {
|
||||
versionId,
|
||||
userId: session.user.id,
|
||||
status: "PENDING_REVIEW",
|
||||
notes: "Approval undone",
|
||||
},
|
||||
});
|
||||
|
||||
// Reset version approval status
|
||||
await db.version.update({
|
||||
where: { id: versionId },
|
||||
data: { approvalStatus: "PENDING_REVIEW" },
|
||||
});
|
||||
|
||||
// Reset task back to INTERNAL_REVIEW if it was moved to DONE
|
||||
if (version.task && version.task.status === "DONE") {
|
||||
await db.task.update({
|
||||
where: { id: version.task.id },
|
||||
data: { status: "INTERNAL_REVIEW" },
|
||||
// One decision, one write — same reasoning as submitApproval.
|
||||
await db.$transaction(async (tx) => {
|
||||
// Create an audit record for the unapproval
|
||||
await tx.approval.create({
|
||||
data: {
|
||||
versionId,
|
||||
userId: session.user.id,
|
||||
status: "PENDING_REVIEW",
|
||||
notes: "Approval undone",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Recalculate shot status
|
||||
if (version.task?.shot) {
|
||||
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
||||
}
|
||||
// Reset version approval status
|
||||
await tx.version.update({
|
||||
where: { id: versionId },
|
||||
data: { approvalStatus: "PENDING_REVIEW" },
|
||||
});
|
||||
|
||||
// Reset task back to INTERNAL_REVIEW if it was moved to DONE
|
||||
if (version.task && version.task.status === "DONE") {
|
||||
await tx.task.update({
|
||||
where: { id: version.task.id },
|
||||
data: { status: "INTERNAL_REVIEW" },
|
||||
});
|
||||
}
|
||||
|
||||
// Undoing a version approval also invalidates any shot-level sign-off that
|
||||
// rested on it, otherwise the shot keeps reading COMPLETE / READY_FOR_CLIENT.
|
||||
if (version.task?.shot) {
|
||||
await clearStaleShotApproval(tx, version.task.shot.id);
|
||||
await recalcShotStatus(version.task.shot.id, tx);
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath(`/review/${versionId}`);
|
||||
if (version.task) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { PipelineError } from "@/lib/render-pipeline/errors";
|
||||
import {
|
||||
createMovReviewPackage,
|
||||
previewMovReviewPackage,
|
||||
} from "@/lib/render-pipeline/reviews";
|
||||
|
||||
/**
|
||||
* Server actions behind "Build MOV Review Package" on the shot-status page
|
||||
* (RenderPipeline2 §11.6). The heavy lifting lives in lib/render-pipeline;
|
||||
* these only carry the session and turn service errors into messages a toast
|
||||
* can show.
|
||||
*/
|
||||
|
||||
function message(err: unknown): string {
|
||||
if (err instanceof PipelineError) return err.message;
|
||||
return err instanceof Error ? err.message : "Unknown error";
|
||||
}
|
||||
|
||||
/** What the confirm dialog shows: the folder, the resolved versions, the gaps. */
|
||||
export async function previewMovReview(shotIds: string[]) {
|
||||
try {
|
||||
await requirePipelineAdmin();
|
||||
const preview = await previewMovReviewPackage(shotIds);
|
||||
return { ok: true as const, ...preview };
|
||||
} catch (err) {
|
||||
return { ok: false as const, error: message(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildMovReview(shotIds: string[]) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const result = await createMovReviewPackage({ shotIds }, user.id);
|
||||
revalidatePath("/pipeline/deliveries");
|
||||
return { ok: true as const, ...result };
|
||||
} catch (err) {
|
||||
return { ok: false as const, error: message(err) };
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -4,7 +4,8 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ProjectStatus } from "@prisma/client";
|
||||
import { Prisma, ProjectStatus } from "@prisma/client";
|
||||
import { pipelineConfigSchema } from "@/lib/render-pipeline/project-templates";
|
||||
|
||||
const createProjectSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -76,6 +77,8 @@ const updateProjectSchema = z.object({
|
||||
startDate: z.string().optional().nullable(),
|
||||
slackWebhook: z.string().url().optional().or(z.literal("")).nullable(),
|
||||
slackChannel: z.string().optional().nullable(),
|
||||
/** Per-production AE workspace: template AEP, renders root, comp overrides. */
|
||||
pipelineConfig: pipelineConfigSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
export async function updateProject(data: z.infer<typeof updateProjectSchema>) {
|
||||
@@ -103,6 +106,19 @@ export async function updateProject(data: z.infer<typeof updateProjectSchema>) {
|
||||
...(rest.startDate !== undefined && { startDate: rest.startDate ? new Date(rest.startDate) : null }),
|
||||
...(rest.slackWebhook !== undefined && { slackWebhook: rest.slackWebhook || null }),
|
||||
...(rest.slackChannel !== undefined && { slackChannel: rest.slackChannel || null }),
|
||||
// Blank fields are dropped rather than stored as empty strings, so a comp
|
||||
// name left blank keeps following the convention instead of pinning "".
|
||||
// Booleans are kept as-is: `false` is a meaningful stored value, and the
|
||||
// blank-string test would otherwise discard every non-string setting.
|
||||
...(rest.pipelineConfig !== undefined && {
|
||||
pipelineConfig: rest.pipelineConfig
|
||||
? (Object.fromEntries(
|
||||
Object.entries(rest.pipelineConfig).filter(([, v]) =>
|
||||
typeof v === "boolean" ? true : typeof v === "string" && !!v.trim()
|
||||
)
|
||||
) as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+405
-47
@@ -4,8 +4,8 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ShotStatus, ShotPriority, ShotApprovalStatus } from "@prisma/client";
|
||||
import { recalcShotStatus } from "@/lib/shot-status";
|
||||
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
|
||||
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
|
||||
|
||||
const createShotSchema = z.object({
|
||||
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
|
||||
@@ -341,6 +341,15 @@ export async function importShotsFromCsv(
|
||||
return { success: true, created, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual override of the shot's status.
|
||||
*
|
||||
* Deliberately the one place that writes Shot.status without deriving it — but
|
||||
* note the override is transient: Shot.status is a derived field, so the next
|
||||
* recalc (any task move, approval, upload or render) recomputes it from the
|
||||
* tasks and approval flags and discards whatever was set here. Change the
|
||||
* underlying task states if the new status needs to stick.
|
||||
*/
|
||||
export async function updateShotStatus(
|
||||
shotId: string,
|
||||
status: ShotStatus
|
||||
@@ -489,6 +498,42 @@ export async function updateShotNotes(shotId: string, notes: string) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Update Shot Slate Fields ──────────────────────────────────────────────────
|
||||
|
||||
export async function updateShotSlate(
|
||||
shotId: string,
|
||||
data: {
|
||||
slateType?: string | null;
|
||||
slateDescription?: string | null;
|
||||
slateVfxScope?: string | null;
|
||||
slateSubmissionNote?: string | null;
|
||||
clientCode?: string | null;
|
||||
}
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
throw new Error("Insufficient permissions");
|
||||
}
|
||||
|
||||
const shot = await db.shot.findUnique({ where: { id: shotId }, select: { projectId: true } });
|
||||
if (!shot) throw new Error("Shot not found");
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: {
|
||||
slateType: data.slateType !== undefined ? (data.slateType?.trim() || null) : undefined,
|
||||
slateDescription: data.slateDescription !== undefined ? (data.slateDescription?.trim() || null) : undefined,
|
||||
slateVfxScope: data.slateVfxScope !== undefined ? (data.slateVfxScope?.trim() || null) : undefined,
|
||||
slateSubmissionNote: data.slateSubmissionNote !== undefined ? (data.slateSubmissionNote?.trim() || null) : undefined,
|
||||
clientCode: data.clientCode !== undefined ? (data.clientCode?.trim() || null) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Toggle Key Shot ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
|
||||
@@ -514,6 +559,34 @@ export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaled in the edit: the plate is not used at its native size, which changes
|
||||
* how the shot is comped and reviewed. Same shape as toggleKeyShot — a standing
|
||||
* property of the shot, flipped from the shot-status board.
|
||||
*/
|
||||
export async function toggleScaledShot(shotId: string, isScaled: boolean) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
throw new Error("Insufficient permissions");
|
||||
}
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
select: { projectId: true },
|
||||
});
|
||||
if (!shot) throw new Error("Shot not found");
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { isScaled },
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
revalidatePath(`/shot-status`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteShot(shotId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
@@ -579,6 +652,59 @@ export async function bulkUpdateDueDates(
|
||||
return { success: true, updated: shots.length };
|
||||
}
|
||||
|
||||
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function bulkDeleteShots(shotIds: string[]) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
throw new Error("Insufficient permissions");
|
||||
}
|
||||
|
||||
if (!shotIds.length) return { success: true, deleted: 0, failed: [] as { shotId: string; shotCode: string; reason: string }[] };
|
||||
|
||||
const shots = await db.shot.findMany({
|
||||
where: { id: { in: shotIds } },
|
||||
select: { id: true, shotCode: true, projectId: true },
|
||||
});
|
||||
|
||||
const projectIds = [...new Set(shots.map((s) => s.projectId))];
|
||||
const failed: { shotId: string; shotCode: string; reason: string }[] = [];
|
||||
let deleted = 0;
|
||||
|
||||
// One at a time, not deleteMany: a shot with renders or deliveries is
|
||||
// protected at the database level (exports_shotId_fkey is ON DELETE
|
||||
// RESTRICT, deliberately — delivery history must never be destroyable by
|
||||
// deleting the shot underneath it). A single bulk statement would abort
|
||||
// entirely on the first shot it hits; deleting individually means every
|
||||
// shot that *can* go still does, and the ones that can't are reported by
|
||||
// name instead of failing the whole selection.
|
||||
for (const shot of shots) {
|
||||
try {
|
||||
await db.shot.delete({ where: { id: shot.id } });
|
||||
deleted += 1;
|
||||
} catch (e) {
|
||||
failed.push({
|
||||
shotId: shot.id,
|
||||
shotCode: shot.shotCode,
|
||||
reason:
|
||||
e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2003"
|
||||
? "has renders or deliveries — delete those first"
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: "unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const pid of projectIds) {
|
||||
revalidatePath(`/projects/${pid}`);
|
||||
}
|
||||
revalidatePath(`/shot-status`);
|
||||
|
||||
return { success: true, deleted, failed };
|
||||
}
|
||||
|
||||
export async function renameFootagePlate(plateId: string, label: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
@@ -620,13 +746,19 @@ export async function internallyApproveShot(shotId: string) {
|
||||
});
|
||||
if (!shot) throw new Error("Shot not found");
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: {
|
||||
shotApprovalStatus: "INTERNALLY_APPROVED",
|
||||
sharedWithClient: false,
|
||||
status: "READY_FOR_CLIENT",
|
||||
},
|
||||
// Set the approval inputs and let the derivation pick the status. Writing
|
||||
// READY_FOR_CLIENT directly skipped the rule that an outstanding CHANGES task
|
||||
// outranks any approval, so a shot could be pushed past open rework and then
|
||||
// snap back to REVISIONS on the next unrelated recalc.
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: {
|
||||
shotApprovalStatus: "INTERNALLY_APPROVED",
|
||||
sharedWithClient: false,
|
||||
},
|
||||
});
|
||||
await recalcShotStatus(shotId, tx);
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
@@ -669,7 +801,7 @@ export async function shareWithClient(shotId: string) {
|
||||
// Mark shot as shared
|
||||
await tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { sharedWithClient: true, status: "CLIENT_REVIEW" },
|
||||
data: { sharedWithClient: true },
|
||||
});
|
||||
|
||||
// For each task, make the latest version client-visible
|
||||
@@ -689,6 +821,8 @@ export async function shareWithClient(shotId: string) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recalcShotStatus(shotId, tx);
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
@@ -721,7 +855,7 @@ export async function unshareFromClient(shotId: string) {
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { sharedWithClient: false, status: "READY_FOR_CLIENT" },
|
||||
data: { sharedWithClient: false },
|
||||
});
|
||||
|
||||
// Hide all client-visible versions on this shot's tasks
|
||||
@@ -732,6 +866,8 @@ export async function unshareFromClient(shotId: string) {
|
||||
data: { isClientVisible: false },
|
||||
});
|
||||
}
|
||||
|
||||
await recalcShotStatus(shotId, tx);
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
@@ -760,9 +896,12 @@ export async function clientApproveShot(shotId: string) {
|
||||
if (!shot) throw new Error("Shot not found");
|
||||
if (!shot.sharedWithClient) throw new Error("Shot is not shared with client");
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { shotApprovalStatus: "CLIENT_APPROVED" },
|
||||
});
|
||||
await recalcShotStatus(shotId, tx);
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
@@ -792,14 +931,15 @@ export async function unapproveShot(shotId: string) {
|
||||
throw new Error("Shot is not client-approved");
|
||||
}
|
||||
|
||||
const newStatus = shot.sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT";
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: {
|
||||
shotApprovalStatus: "INTERNALLY_APPROVED",
|
||||
status: newStatus,
|
||||
},
|
||||
// The derivation already encodes "CLIENT_REVIEW if still shared, else
|
||||
// READY_FOR_CLIENT" for an INTERNALLY_APPROVED shot, and additionally honours
|
||||
// any open CHANGES task.
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { shotApprovalStatus: "INTERNALLY_APPROVED" },
|
||||
});
|
||||
await recalcShotStatus(shotId, tx);
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
@@ -951,10 +1091,161 @@ export async function updateShotsSeqTimecodes(
|
||||
return { updated, skipped, errors };
|
||||
}
|
||||
|
||||
// ── Simple CSV shot import ────────────────────────────────────────────────────
|
||||
|
||||
export interface SimpleCsvRow {
|
||||
shotCode: string;
|
||||
seqTimecodeStart: string;
|
||||
seqTimecodeEnd: string;
|
||||
description: string;
|
||||
group: string;
|
||||
action: "create" | "update" | "skip";
|
||||
}
|
||||
|
||||
export async function importShotsFromSimpleCsv(
|
||||
projectId: string,
|
||||
rows: SimpleCsvRow[]
|
||||
): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] }> {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
throw new Error("Insufficient permissions");
|
||||
}
|
||||
|
||||
const created: string[] = [];
|
||||
const updated: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.action === "skip") { skipped.push(row.shotCode); continue; }
|
||||
try {
|
||||
const existing = await db.shot.findFirst({
|
||||
where: { projectId, shotCode: row.shotCode },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const shotGroupId = row.group?.trim()
|
||||
? (await db.shotGroup.upsert({
|
||||
where: { projectId_name: { projectId, name: row.group.trim() } },
|
||||
create: { projectId, name: row.group.trim() },
|
||||
update: {},
|
||||
})).id
|
||||
: undefined;
|
||||
await db.shot.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
description: row.description || null,
|
||||
seqTimecodeStart: row.seqTimecodeStart || null,
|
||||
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
||||
...(shotGroupId ? { shotGroupId } : {}),
|
||||
},
|
||||
});
|
||||
updated.push(row.shotCode);
|
||||
} else if (row.action === "create") {
|
||||
const parts = row.shotCode.split("_");
|
||||
const scene = parts.length >= 3 ? parts[2] : (parts[1] ?? "000");
|
||||
const episode = parts.length >= 4 ? parts[1] : null;
|
||||
const maxNum = await db.shot.findFirst({
|
||||
where: { projectId, scene, episode },
|
||||
orderBy: { shotNumber: "desc" },
|
||||
select: { shotNumber: true },
|
||||
});
|
||||
const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
|
||||
const shotGroupId = row.group?.trim()
|
||||
? (await db.shotGroup.upsert({
|
||||
where: { projectId_name: { projectId, name: row.group.trim() } },
|
||||
create: { projectId, name: row.group.trim() },
|
||||
update: {},
|
||||
})).id
|
||||
: undefined;
|
||||
|
||||
await db.shot.create({
|
||||
data: {
|
||||
projectId,
|
||||
shotCode: row.shotCode,
|
||||
scene,
|
||||
episode,
|
||||
shotNumber,
|
||||
description: row.description || null,
|
||||
seqTimecodeStart: row.seqTimecodeStart || null,
|
||||
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
||||
shotGroupId,
|
||||
},
|
||||
});
|
||||
created.push(row.shotCode);
|
||||
} else {
|
||||
// action === "update" but shot not found
|
||||
skipped.push(row.shotCode);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
return { created, updated, skipped, errors };
|
||||
}
|
||||
|
||||
// ── Group Assignment ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GroupAssignRow {
|
||||
shotCode: string;
|
||||
groupName: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export async function assignShotGroups(
|
||||
projectId: string,
|
||||
rows: GroupAssignRow[]
|
||||
): Promise<{ assigned: string[]; skipped: string[]; errors: string[] }> {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
throw new Error("Insufficient permissions");
|
||||
}
|
||||
|
||||
const assigned: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.exists) { skipped.push(row.shotCode); continue; }
|
||||
try {
|
||||
const shot = await db.shot.findFirst({
|
||||
where: { projectId, shotCode: row.shotCode },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!shot) { skipped.push(row.shotCode); continue; }
|
||||
|
||||
const group = await db.shotGroup.upsert({
|
||||
where: { projectId_name: { projectId, name: row.groupName.trim() } },
|
||||
create: { projectId, name: row.groupName.trim() },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shot.id },
|
||||
data: { shotGroupId: group.id },
|
||||
});
|
||||
assigned.push(row.shotCode);
|
||||
} catch (e) {
|
||||
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
return { assigned, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: handle client requesting changes on a shot.
|
||||
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
||||
* Recalculates shot status (will become REVISIONS if tasks set to CHANGES).
|
||||
* Session-authenticated wrapper for requesting changes on a shot — a supervisor
|
||||
* or producer relaying a client note from inside the app.
|
||||
*
|
||||
* The client portal cannot use this: it runs on a review token, not a session,
|
||||
* so it calls markShotForRevisions directly from its route. Both go through that
|
||||
* one helper so the two entry points cannot drift apart again.
|
||||
*/
|
||||
export async function clientRequestShotChanges(shotId: string, taskId?: string) {
|
||||
const session = await auth();
|
||||
@@ -969,28 +1260,7 @@ export async function clientRequestShotChanges(shotId: string, taskId?: string)
|
||||
});
|
||||
if (!shot) throw new Error("Shot not found");
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
// Reset shot approval
|
||||
await tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { shotApprovalStatus: "PENDING", sharedWithClient: false },
|
||||
});
|
||||
|
||||
// If a specific task is called out, mark it CHANGES; otherwise mark all non-DONE tasks
|
||||
if (taskId) {
|
||||
await tx.task.update({
|
||||
where: { id: taskId },
|
||||
data: { status: "CHANGES" },
|
||||
});
|
||||
} else {
|
||||
await tx.task.updateMany({
|
||||
where: { shotId, status: { not: "DONE" } },
|
||||
data: { status: "CHANGES" },
|
||||
});
|
||||
}
|
||||
|
||||
await recalcShotStatus(shotId, tx);
|
||||
});
|
||||
await db.$transaction((tx) => markShotForRevisions(tx, shotId, taskId));
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||
@@ -1029,3 +1299,91 @@ export async function updateShotVersion(shotId: string, version: string) {
|
||||
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||
return { success: true, shotVersion: version };
|
||||
}
|
||||
|
||||
// ── Generated (render-derived) shot thumbnails ───────────────────────────────
|
||||
//
|
||||
// A shot can carry two stills: `thumbnailUrl`, the reference frame that came
|
||||
// with the brief, and `generatedThumbnailUrl`, a frame promoted from a render
|
||||
// export. The reference always wins on display (lib/shot-thumbnail.ts), so
|
||||
// these actions can only ever fill a gap — a promotion never overwrites the
|
||||
// brief, and clearing one always falls back to it.
|
||||
//
|
||||
// Shows with `pipelineConfig.autoShotThumbnail` promote on every finished
|
||||
// export automatically (lib/render-pipeline/preview.ts). These actions are the
|
||||
// manual equivalent, and work regardless of that flag: a supervisor can pull a
|
||||
// frame onto one shot on a project that is otherwise hands-off.
|
||||
|
||||
/**
|
||||
* Promote a render export's thumbnail onto its shot.
|
||||
*
|
||||
* With no `exportId`, uses the newest export of that shot which actually has a
|
||||
* thumbnail — "use the latest render frame".
|
||||
*/
|
||||
export async function promoteExportThumbnail(shotId: string, exportId?: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
throw new Error("Insufficient permissions");
|
||||
}
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
if (!shot) throw new Error("Shot not found");
|
||||
|
||||
const exportRow = await db.export.findFirst({
|
||||
where: {
|
||||
shotId,
|
||||
thumbnailKey: { not: null },
|
||||
...(exportId ? { id: exportId } : {}),
|
||||
},
|
||||
orderBy: { versionNumber: "desc" },
|
||||
select: { id: true, thumbnailKey: true, versionString: true },
|
||||
});
|
||||
if (!exportRow?.thumbnailKey) {
|
||||
throw new Error(
|
||||
exportId
|
||||
? "That export has no thumbnail — its preview may have been skipped, or the worker had no ffmpeg configured"
|
||||
: "No render of this shot has produced a thumbnail yet"
|
||||
);
|
||||
}
|
||||
|
||||
const generatedThumbnailUrl = `/api/files/${exportRow.thumbnailKey}`;
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { generatedThumbnailUrl, generatedThumbnailFrom: exportRow.versionString },
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||
return {
|
||||
success: true,
|
||||
generatedThumbnailUrl,
|
||||
generatedThumbnailFrom: exportRow.versionString,
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop the render-derived still. The reference frame, if any, comes back. */
|
||||
export async function clearGeneratedThumbnail(shotId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
throw new Error("Insufficient permissions");
|
||||
}
|
||||
|
||||
const shot = await db.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
if (!shot) throw new Error("Shot not found");
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { generatedThumbnailUrl: null, generatedThumbnailFrom: null },
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${shot.projectId}`);
|
||||
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
+10
-3
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { recalcShotStatus } from "@/lib/shot-status";
|
||||
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
|
||||
import { notifyNewVersionUploaded } from "@/lib/notifications";
|
||||
import { slackNotifyVersionUploaded } from "@/lib/slack";
|
||||
|
||||
@@ -50,9 +50,16 @@ export async function createVersion(data: z.infer<typeof createVersionSchema>) {
|
||||
data: { status: "INTERNAL_REVIEW" },
|
||||
});
|
||||
|
||||
// Recalculate shot status
|
||||
// Recalculate shot status — clearing any stale sign-off first, so a new
|
||||
// version does not land under a shot still reading COMPLETE.
|
||||
if (task.shot) {
|
||||
await recalcShotStatus(task.shot.id).catch(() => {});
|
||||
const shotId = task.shot.id;
|
||||
await db
|
||||
.$transaction(async (tx) => {
|
||||
await clearStaleShotApproval(tx, shotId);
|
||||
await recalcShotStatus(shotId, tx);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Get the next version number for this task
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// After a redeployment the old chunk URLs 404 — force a hard reload to pick up new manifest
|
||||
if (error?.name === "ChunkLoadError") {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
if (error?.name === "ChunkLoadError") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-zinc-400">
|
||||
<AlertTriangle className="w-10 h-10 text-amber-500" />
|
||||
<p className="text-lg font-medium text-zinc-200">Something went wrong</p>
|
||||
{error?.message && (
|
||||
<p className="text-sm text-zinc-500 max-w-md text-center">{error.message}</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={reset} className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
EXPORT_STATUS_STYLES,
|
||||
FAILED_STATUSES,
|
||||
ACTIVE_STATUSES,
|
||||
statusLabel,
|
||||
formatEta,
|
||||
} from "./status-colors";
|
||||
|
||||
interface QueueExport {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
episode: string | null;
|
||||
projectName: string;
|
||||
projectCode: string;
|
||||
versionString: string;
|
||||
status: string;
|
||||
statusChangedAt: string;
|
||||
createdAt: string;
|
||||
outputDir: string;
|
||||
job: {
|
||||
id: string;
|
||||
attempt: number;
|
||||
status: string;
|
||||
progress: number;
|
||||
currentFrame: number | null;
|
||||
totalFrames: number | null;
|
||||
etaSeconds: number | null;
|
||||
priority: number;
|
||||
machineName: string | null;
|
||||
errorMessage: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projects: { id: string; name: string; code: string }[];
|
||||
}
|
||||
|
||||
export function PipelineQueueClient({ projects }: Props) {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [projectId, setProjectId] = useState<string>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["pipeline-queue", projectId, statusFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
if (projectId !== "all") params.set("projectId", projectId);
|
||||
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||
const res = await fetch(`/api/render/queue?${params}`);
|
||||
if (!res.ok) throw new Error("Failed to load queue");
|
||||
return res.json() as Promise<{ exports: QueueExport[]; pagination: { total: number } }>;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const exports = data?.exports ?? [];
|
||||
const counts = {
|
||||
queued: exports.filter((e) => e.status === "QUEUED").length,
|
||||
rendering: exports.filter((e) => e.status === "RENDERING").length,
|
||||
failed: exports.filter((e) => FAILED_STATUSES.includes(e.status)).length,
|
||||
readyForQc: exports.filter((e) => e.status === "READY_FOR_QC").length,
|
||||
readyForDelivery: exports.filter((e) => e.status === "READY_FOR_DELIVERY").length,
|
||||
};
|
||||
|
||||
async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") {
|
||||
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: `Export ${verb === "mark-done" ? "marked done" : verb === "retry" ? "requeued" : "cancelled"}` });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-queue"] });
|
||||
}
|
||||
|
||||
const cards: { label: string; value: number; className?: string }[] = [
|
||||
{ label: "Queued", value: counts.queued },
|
||||
{ label: "Rendering", value: counts.rendering, className: "text-blue-400" },
|
||||
{ label: "Failed", value: counts.failed, className: "text-red-400" },
|
||||
{ label: "Ready for QC", value: counts.readyForQc, className: "text-amber-400" },
|
||||
{ label: "Ready for Delivery", value: counts.readyForDelivery, className: "text-green-400" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Render Queue</h1>
|
||||
<p className="text-sm text-zinc-400 mt-1">
|
||||
Exports queued from the AE panel — live status, retries and history
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/pipeline/deliveries">
|
||||
<Button variant="outline" size="sm">
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Deliveries
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pipeline/deliveries">
|
||||
<Button variant="outline" size="sm">
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Deliveries
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pipeline/machines">
|
||||
<Button variant="outline" size="sm">
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
Machines
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
|
||||
<div className={cn("text-2xl font-semibold text-white", c.className)}>{c.value}</div>
|
||||
<div className="text-xs text-zinc-400 mt-0.5">{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All projects</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
|
||||
<th className="px-4 py-2.5 font-medium">Shot</th>
|
||||
<th className="px-4 py-2.5 font-medium">Version</th>
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
<th className="px-4 py-2.5 font-medium w-48">Progress</th>
|
||||
<th className="px-4 py-2.5 font-medium">Machine</th>
|
||||
<th className="px-4 py-2.5 font-medium">Attempt</th>
|
||||
<th className="px-4 py-2.5 font-medium">Queued</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : exports.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
|
||||
No exports yet — queue one from the After Effects panel
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
exports.map((e) => (
|
||||
<tr key={e.id} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/pipeline/exports/${e.id}`} className="text-white hover:text-amber-400 font-medium">
|
||||
{e.shotCode}
|
||||
</Link>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{e.projectCode}
|
||||
{e.episode ? ` · ep ${e.episode}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-300">{e.versionString}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
|
||||
)}
|
||||
title={e.job?.errorMessage ?? undefined}
|
||||
>
|
||||
{statusLabel(e.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{e.status === "RENDERING" && e.job ? (
|
||||
<div className="space-y-1">
|
||||
<Progress value={e.job.progress * 100} className="h-1.5" />
|
||||
<div className="text-xs text-zinc-500">
|
||||
{e.job.currentFrame != null && e.job.totalFrames != null
|
||||
? `frame ${e.job.currentFrame}/${e.job.totalFrames} · `
|
||||
: ""}
|
||||
ETA {formatEta(e.job.etaSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400">{e.job?.machineName ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400">
|
||||
{e.job ? `${e.job.attempt}` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{FAILED_STATUSES.includes(e.status) && (
|
||||
<Button variant="ghost" size="icon-sm" title="Retry" onClick={() => action(e.id, "retry")}>
|
||||
<RotateCcw className="h-4 w-4 text-amber-400" />
|
||||
</Button>
|
||||
)}
|
||||
{ACTIVE_STATUSES.includes(e.status) && (
|
||||
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => action(e.id, "cancel")}>
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Mark done manually (rendered outside the pipeline)"
|
||||
onClick={() => action(e.id, "mark-done")}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<BlenderQueue projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
|
||||
Blender renders
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{jobs.map((j) => (
|
||||
<div key={j.id} className="px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-zinc-200">{j.label ?? "(untitled)"}</span>
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs", BLENDER_STATUS_STYLES[j.status] ?? "")}>
|
||||
{j.status.toLowerCase()}
|
||||
</span>
|
||||
{j.projectCode && <span className="text-xs text-zinc-500">{j.projectCode}</span>}
|
||||
{j.frameStart != null && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
frames {j.frameStart}–{j.frameEnd}
|
||||
</span>
|
||||
)}
|
||||
{j.status === "RUNNING" && (
|
||||
<span className="text-xs text-blue-400">
|
||||
{Math.round(j.progress * 100)}%
|
||||
{j.currentFrame != null ? ` · frame ${j.currentFrame}` : ""}
|
||||
{j.etaSeconds ? ` · ETA ${formatEta(j.etaSeconds)}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{j.machineName && <span className="text-xs text-zinc-500">{j.machineName}</span>}
|
||||
{j.attempt > 1 && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
attempt {j.attempt}/{j.maxAttempts}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{["QUEUED", "CLAIMED", "RUNNING"].includes(j.status) && (
|
||||
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => jobAction(j.id, "cancel")}>
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
</Button>
|
||||
)}
|
||||
{["FAILED", "CANCELLED", "EXPIRED"].includes(j.status) && (
|
||||
<Button variant="ghost" size="icon-sm" title="Requeue" onClick={() => jobAction(j.id, "retry")}>
|
||||
<RotateCcw className="h-4 w-4 text-amber-400" />
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{j.blendFile && <div className="mt-1 text-xs text-zinc-600 break-all">{j.blendFile}</div>}
|
||||
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
|
||||
|
||||
interface ReadyExport {
|
||||
exportId: string;
|
||||
shotCode: string;
|
||||
episode: string;
|
||||
versionString: string;
|
||||
fileCount: number | null;
|
||||
totalBytes: number | null;
|
||||
deliveryMovPath: string | null;
|
||||
hasChecksum: boolean;
|
||||
qc: { result: string; reviewer: string | null; at: string } | null;
|
||||
}
|
||||
|
||||
interface ReadyResponse {
|
||||
episodes: { episode: string; count: number; exports: ReadyExport[] }[];
|
||||
}
|
||||
|
||||
interface PackageRow {
|
||||
id: string;
|
||||
packageNumber: number;
|
||||
name: string;
|
||||
kind: "EXR_DELIVERY" | "MOV_REVIEW";
|
||||
episode: string;
|
||||
status: string;
|
||||
rootPath: string | null;
|
||||
errorMessage: string | null;
|
||||
projectName: string;
|
||||
createdAt: string;
|
||||
builtAt: string | null;
|
||||
deliveredAt: string | null;
|
||||
itemCount: number;
|
||||
verifiedCount: number;
|
||||
items: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
versionString: string;
|
||||
status: string;
|
||||
fileCount: number | null;
|
||||
linkMode: string | null;
|
||||
errorMessage: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes == null) return "—";
|
||||
const gb = bytes / 1_073_741_824;
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1_048_576).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [projectId, setProjectId] = useState<string>("");
|
||||
const [episode, setEpisode] = useState<string>("");
|
||||
const [building, setBuilding] = useState(false);
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ["pipeline-projects"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/projects");
|
||||
if (!res.ok) throw new Error("Failed to load projects");
|
||||
const body = await res.json();
|
||||
return (body.projects ?? body) as ProjectOption[];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: ready, isLoading: readyLoading } = useQuery({
|
||||
queryKey: ["delivery-ready", projectId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/deliveries/ready?projectId=${encodeURIComponent(projectId)}`);
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load ready exports");
|
||||
return res.json() as Promise<ReadyResponse>;
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
|
||||
const { data: packages } = useQuery({
|
||||
queryKey: ["delivery-packages", projectId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/deliveries${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
|
||||
if (!res.ok) throw new Error("Failed to load packages");
|
||||
return res.json() as Promise<{ packages: PackageRow[] }>;
|
||||
},
|
||||
// A build is a queued job like any other — poll while one is in flight.
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
async function build(ep: string) {
|
||||
if (!projectId) return;
|
||||
setBuilding(true);
|
||||
try {
|
||||
const res = await fetch("/api/deliveries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId, episode: ep || null }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: "Build failed", description: body.error ?? res.statusText, variant: "destructive" });
|
||||
} else {
|
||||
toast({
|
||||
title: `Package #${body.package.packageNumber} queued`,
|
||||
description: `${body.package.itemCount} shots → ${body.package.rootPath}`,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
|
||||
}
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function packageAction(id: string, verb: "delivered" | "cancel") {
|
||||
const res = await fetch(`/api/deliveries/${id}/${verb}`, { method: "POST" });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
|
||||
}
|
||||
|
||||
const episodes = ready?.episodes ?? [];
|
||||
const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-6xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pipeline">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-white">Delivery packages</h1>
|
||||
</div>
|
||||
|
||||
{/* Picker */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => {
|
||||
setProjectId(e.target.value);
|
||||
setEpisode("");
|
||||
}}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
|
||||
>
|
||||
<option value="">Select a project…</option>
|
||||
{(projects ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{episodes.length > 0 && (
|
||||
<select
|
||||
value={episode}
|
||||
onChange={(e) => setEpisode(e.target.value)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
|
||||
>
|
||||
<option value="">All episodes</option>
|
||||
{episodes.map((e) => (
|
||||
<option key={e.episode} value={e.episode}>
|
||||
{e.episode || "(no episode)"} — {e.count} ready
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ready to build */}
|
||||
{projectId && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
|
||||
Ready for delivery
|
||||
</div>
|
||||
{readyLoading && <div className="p-4 text-sm text-zinc-500">Loading…</div>}
|
||||
{!readyLoading && shown.length === 0 && (
|
||||
<div className="p-4 text-sm text-zinc-500">
|
||||
Nothing has passed QC yet. Exports appear here once QC passes them.
|
||||
</div>
|
||||
)}
|
||||
{shown.map((ep) => (
|
||||
<div key={ep.episode} className="border-b border-zinc-800/60 last:border-0">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 flex-wrap">
|
||||
<div>
|
||||
<span className="text-white text-sm font-medium">
|
||||
{ep.episode ? `Episode ${ep.episode}` : "No episode"}
|
||||
</span>
|
||||
<span className="ml-3 text-sm text-zinc-400">Ready: {ep.count} shots</span>
|
||||
{ep.exports.some((x) => !x.hasChecksum) && (
|
||||
<span className="ml-3 text-xs text-amber-400">
|
||||
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canBuild && (
|
||||
<Button size="sm" onClick={() => build(ep.episode)} disabled={building}>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Build delivery
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 pb-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{ep.exports.map((x) => (
|
||||
<div key={x.exportId} className="flex items-center gap-2 text-xs">
|
||||
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
|
||||
{x.shotCode}
|
||||
</Link>
|
||||
<span className="text-zinc-500">{x.versionString}</span>
|
||||
<span className="text-zinc-600">{x.fileCount ?? "?"} frames</span>
|
||||
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
|
||||
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History — packages are permanent (§11.5) */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Packages</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{(packages?.packages ?? []).map((p) => (
|
||||
<details key={p.id} className="group">
|
||||
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
|
||||
<span className="text-sm text-white font-medium">#{p.packageNumber}</span>
|
||||
<span className="text-sm text-zinc-300">{p.name}</span>
|
||||
{p.kind === "MOV_REVIEW" && (
|
||||
<span className="rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-xs text-violet-300">
|
||||
MOV review
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-xs",
|
||||
DELIVERY_STATUS_STYLES[p.status] ?? ""
|
||||
)}
|
||||
>
|
||||
{statusLabel(p.status)}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{p.episode ? `episode ${p.episode} · ` : ""}
|
||||
{p.verifiedCount}/{p.itemCount} shots
|
||||
</span>
|
||||
<span className="text-xs text-zinc-600">{new Date(p.createdAt).toLocaleDateString()}</span>
|
||||
{p.status === "BUILDING" && <RefreshCw className="h-3.5 w-3.5 text-blue-400 animate-spin" />}
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{canBuild && p.status === "READY" && p.kind === "EXR_DELIVERY" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
packageAction(p.id, "delivered");
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
|
||||
Mark delivered
|
||||
</Button>
|
||||
)}
|
||||
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
packageAction(p.id, "cancel");
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-400" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{p.rootPath && <div className="text-xs text-zinc-500 break-all">{p.rootPath}</div>}
|
||||
{p.errorMessage && <div className="text-xs text-red-400">{p.errorMessage}</div>}
|
||||
{p.deliveredAt && (
|
||||
<div className="text-xs text-emerald-400">
|
||||
Delivered {new Date(p.deliveredAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{p.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
item.status === "VERIFIED"
|
||||
? "text-emerald-400"
|
||||
: item.status === "FAILED"
|
||||
? "text-red-400"
|
||||
: "text-zinc-500"
|
||||
)}
|
||||
>
|
||||
{item.status === "VERIFIED" ? "✓" : item.status === "FAILED" ? "✕" : "·"}
|
||||
</span>
|
||||
<span className="text-zinc-300">{item.shotCode}</span>
|
||||
<span className="text-zinc-500">{item.versionString}</span>
|
||||
{p.kind === "EXR_DELIVERY" && item.fileCount != null && (
|
||||
<span className="text-zinc-600">{item.fileCount}f</span>
|
||||
)}
|
||||
{item.linkMode && <span className="text-zinc-600">{item.linkMode}</span>}
|
||||
{item.errorMessage && <span className="text-red-400">{item.errorMessage}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
{(packages?.packages ?? []).length === 0 && (
|
||||
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DeliveriesClient } from "./DeliveriesClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DeliveriesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
return <DeliveriesClient canBuild={["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)} />;
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, RotateCcw, XCircle, ShieldAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
EXPORT_STATUS_STYLES,
|
||||
FAILED_STATUSES,
|
||||
ACTIVE_STATUSES,
|
||||
statusLabel,
|
||||
} from "../../status-colors";
|
||||
|
||||
interface ExportDetail {
|
||||
export: {
|
||||
id: string;
|
||||
status: string;
|
||||
versionString: string;
|
||||
statusChangedAt: string;
|
||||
createdAt: string;
|
||||
aepPath: string;
|
||||
compName: string;
|
||||
outputDir: string;
|
||||
outputPattern: string;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
colorspace: string | null;
|
||||
exrFileCount: number | null;
|
||||
exrTotalBytes: number | null;
|
||||
vfxScope: string | null;
|
||||
submissionNote: string | null;
|
||||
submittedByName: string | null;
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
episode: string | null;
|
||||
project: { id: string; name: string; code: string };
|
||||
};
|
||||
renderJobs: {
|
||||
id: string;
|
||||
attempt: number;
|
||||
status: string;
|
||||
priority: number;
|
||||
progress: number;
|
||||
currentFrame: number | null;
|
||||
totalFrames: number | null;
|
||||
errorMessage: string | null;
|
||||
logTail: string | null;
|
||||
exitCode: number | null;
|
||||
claimedAt: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
machine: { id: string; name: string } | null;
|
||||
}[];
|
||||
events: {
|
||||
id: string;
|
||||
fromStatus: string | null;
|
||||
toStatus: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}[];
|
||||
validations: {
|
||||
id: string;
|
||||
renderJobId: string;
|
||||
checkName: string;
|
||||
status: string;
|
||||
expected: string | null;
|
||||
actual: string | null;
|
||||
message: string | null;
|
||||
details: unknown;
|
||||
createdAt: string;
|
||||
}[];
|
||||
qcReviews: {
|
||||
id: string;
|
||||
result: string;
|
||||
notes: string | null;
|
||||
reviewerName: string | null;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
}[];
|
||||
deliveryItems: {
|
||||
id: string;
|
||||
status: string;
|
||||
exrPath: string | null;
|
||||
linkMode: string | null;
|
||||
package: { id: string; name: string; episode: string; status: string; packageNumber: number };
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
const CHECK_STYLES: Record<string, string> = {
|
||||
PASS: "text-emerald-400",
|
||||
FAIL: "text-red-400",
|
||||
WARN: "text-amber-400",
|
||||
SKIPPED: "text-zinc-500",
|
||||
};
|
||||
|
||||
export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [overrideOpen, setOverrideOpen] = useState(false);
|
||||
const [overrideReason, setOverrideReason] = useState("");
|
||||
const [overrideSkipPreview, setOverrideSkipPreview] = useState(false);
|
||||
const [overriding, setOverriding] = useState(false);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["pipeline-export", exportId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/render/exports/${exportId}`);
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? "Failed to load export");
|
||||
return res.json() as Promise<ExportDetail>;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
async function action(verb: "retry" | "cancel") {
|
||||
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
|
||||
}
|
||||
|
||||
async function overrideValidation() {
|
||||
setOverriding(true);
|
||||
try {
|
||||
const res = await fetch(`/api/render/exports/${exportId}/override-validation`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: overrideReason, skipPreview: overrideSkipPreview }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: "Override failed", description: body.error ?? res.statusText, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Validation overridden",
|
||||
description: `${(body.overridden ?? []).join(", ") || "No failed checks recorded"} — continuing to preview and QC`,
|
||||
});
|
||||
setOverrideOpen(false);
|
||||
setOverrideReason("");
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
|
||||
} finally {
|
||||
setOverriding(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="p-6 text-red-400">
|
||||
{(error as Error)?.message ?? "Export not found"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const e = data.export;
|
||||
const manifestRows: [string, string][] = [
|
||||
["Project", `${e.shot.project.name} (${e.shot.project.code})`],
|
||||
["Shot", e.shot.shotCode],
|
||||
["Comp", e.compName],
|
||||
["AEP", e.aepPath],
|
||||
["Output dir", e.outputDir],
|
||||
["Pattern", e.outputPattern],
|
||||
["Frames", `${e.frameStart}–${e.frameEnd} @ ${e.fps} fps`],
|
||||
["Resolution", `${e.width}×${e.height}`],
|
||||
["Colorspace", e.colorspace ?? "—"],
|
||||
["EXR files", e.exrFileCount != null ? String(e.exrFileCount) : "—"],
|
||||
["Submitted by", e.submittedByName ?? "—"],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pipeline">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">
|
||||
{e.shot.shotCode} · {e.versionString}
|
||||
</h1>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block mt-1 rounded-full border px-2 py-0.5 text-xs",
|
||||
EXPORT_STATUS_STYLES[e.status] ?? ""
|
||||
)}
|
||||
>
|
||||
{statusLabel(e.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{FAILED_STATUSES.includes(e.status) && (
|
||||
<Button variant="outline" size="sm" onClick={() => action("retry")}>
|
||||
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
|
||||
</Button>
|
||||
)}
|
||||
{e.status === "VALIDATION_FAILED" && !overrideOpen && (
|
||||
<Button variant="outline" size="sm" onClick={() => setOverrideOpen(true)}>
|
||||
<ShieldAlert className="h-4 w-4 mr-2 text-amber-400" /> Override validation
|
||||
</Button>
|
||||
)}
|
||||
{ACTIVE_STATUSES.includes(e.status) && (
|
||||
<Button variant="outline" size="sm" onClick={() => action("cancel")}>
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-400" /> Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Override — a human overruling the checks, so the reason is required
|
||||
and kept on the export's timeline. */}
|
||||
{overrideOpen && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-3">
|
||||
<div className="text-sm text-amber-200">
|
||||
Override the failed validation for {e.shot.shotCode} {e.versionString}?
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400">
|
||||
The render continues to preview and then QC — it is not delivered directly, and the failed
|
||||
checks stay on record and still show up in the QC queue. Say why, so the next person
|
||||
reading this export knows what was waved through.
|
||||
</p>
|
||||
<textarea
|
||||
value={overrideReason}
|
||||
onChange={(ev) => setOverrideReason(ev.target.value)}
|
||||
rows={2}
|
||||
autoFocus
|
||||
placeholder="e.g. plate unreachable from the render node; timecode checked by hand against the EDL"
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
<label className="flex items-start gap-2 text-xs text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overrideSkipPreview}
|
||||
onChange={(ev) => setOverrideSkipPreview(ev.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Skip the preview build and reuse the previous export's review media.
|
||||
<span className="block text-zinc-500">
|
||||
For EXRs framed differently from the review — unscaled or un-punched-in — where a
|
||||
preview rebuilt from these frames would be the wrong picture.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={overrideValidation}
|
||||
disabled={overrideReason.trim().length < 3 || overriding}
|
||||
>
|
||||
{overriding ? "Overriding…" : "Override and continue"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOverrideOpen(false);
|
||||
setOverrideReason("");
|
||||
setOverrideSkipPreview(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manifest */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Manifest</div>
|
||||
<dl className="grid sm:grid-cols-2 gap-x-8 gap-y-2 p-4 text-sm">
|
||||
{manifestRows.map(([k, v]) => (
|
||||
<div key={k} className="flex gap-3">
|
||||
<dt className="w-28 shrink-0 text-zinc-500">{k}</dt>
|
||||
<dd className="text-zinc-300 break-all">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Per-submission slate fields */}
|
||||
{(e.vfxScope || e.submissionNote) && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
|
||||
Submission
|
||||
</div>
|
||||
<dl className="p-4 space-y-3 text-sm">
|
||||
{e.vfxScope && (
|
||||
<div>
|
||||
<dt className="text-zinc-500 mb-0.5">VFX Scope</dt>
|
||||
<dd className="text-zinc-300 whitespace-pre-wrap">{e.vfxScope}</dd>
|
||||
</div>
|
||||
)}
|
||||
{e.submissionNote && (
|
||||
<div>
|
||||
<dt className="text-zinc-500 mb-0.5">Submission Note</dt>
|
||||
<dd className="text-zinc-300 whitespace-pre-wrap">{e.submissionNote}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation results — newest run first */}
|
||||
{e.validations.length > 0 && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white flex items-center gap-3">
|
||||
Validation
|
||||
{/* A waved-through failure must stay visible long after the event
|
||||
scrolls down the timeline. */}
|
||||
{e.events.some((ev) => ev.note?.startsWith("Validation overridden")) && (
|
||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-400">
|
||||
overridden
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const latestRun = e.validations[e.validations.length - 1].renderJobId;
|
||||
const rows = e.validations.filter((v) => v.renderJobId === latestRun);
|
||||
const failed = rows.filter((v) => v.status === "FAIL").length;
|
||||
const warned = rows.filter((v) => v.status === "WARN").length;
|
||||
return (
|
||||
<span className={cn("text-xs", failed ? "text-red-400" : warned ? "text-amber-400" : "text-emerald-400")}>
|
||||
{failed ? `${failed} failed` : warned ? `${warned} warning${warned > 1 ? "s" : ""}` : "all checks passed"}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{(() => {
|
||||
const latestRun = e.validations[e.validations.length - 1].renderJobId;
|
||||
// Failures first: the reason the export stopped should not need scrolling for.
|
||||
const order = { FAIL: 0, WARN: 1, PASS: 2, SKIPPED: 3 } as Record<string, number>;
|
||||
return e.validations
|
||||
.filter((v) => v.renderJobId === latestRun)
|
||||
.slice()
|
||||
.sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9))
|
||||
.map((v) => (
|
||||
<div key={v.id} className="px-4 py-2 text-sm flex items-start gap-3">
|
||||
<span className={cn("w-16 shrink-0 text-xs font-medium", CHECK_STYLES[v.status] ?? "")}>
|
||||
{v.status}
|
||||
</span>
|
||||
<span className="w-48 shrink-0 text-zinc-300 font-mono text-xs pt-0.5">{v.checkName}</span>
|
||||
<span className="text-zinc-400 text-xs pt-0.5">
|
||||
{v.message ??
|
||||
(v.expected === v.actual ? v.actual : `expected ${v.expected ?? "—"}, got ${v.actual ?? "—"}`)}
|
||||
</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QC history — latest decision is the authoritative one */}
|
||||
{e.qcReviews.length > 0 && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">QC</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{e.qcReviews.map((q) => (
|
||||
<div key={q.id} className="px-4 py-2.5 text-sm">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className={cn("text-xs font-medium", q.result === "PASS" ? "text-emerald-400" : "text-red-400")}>
|
||||
QC {q.result}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-400">{q.reviewerName ?? "unknown reviewer"}</span>
|
||||
<span className="text-xs text-zinc-500">{q.source === "AE_PANEL" ? "AE panel" : "web"}</span>
|
||||
<span className="text-xs text-zinc-500">{new Date(q.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
{q.notes && <div className="mt-1 text-xs text-zinc-400 whitespace-pre-wrap">{q.notes}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery membership */}
|
||||
{e.deliveryItems.length > 0 && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Deliveries</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{e.deliveryItems.map((d) => (
|
||||
<div key={d.id} className="px-4 py-2.5 text-sm flex items-center gap-3 flex-wrap">
|
||||
<Link href="/pipeline/deliveries" className="text-zinc-200 hover:text-white">
|
||||
#{d.package.packageNumber} · {d.package.name}
|
||||
</Link>
|
||||
<span className="text-xs text-zinc-500">episode {d.package.episode || "—"}</span>
|
||||
<span className="text-xs text-zinc-400">{d.package.status}</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
item {d.status.toLowerCase()}
|
||||
{d.linkMode ? ` · ${d.linkMode}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render attempts */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
|
||||
Render attempts
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{e.renderJobs.map((j) => (
|
||||
<div key={j.id} className="p-4 text-sm">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-white font-medium">Attempt {j.attempt}</span>
|
||||
<span className="text-xs text-zinc-400">{j.status}</span>
|
||||
<span className="text-xs text-zinc-500">{j.machine?.name ?? "unclaimed"}</span>
|
||||
{j.exitCode != null && <span className="text-xs text-zinc-500">exit {j.exitCode}</span>}
|
||||
{j.startedAt && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
started {new Date(j.startedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{j.finishedAt && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
finished {new Date(j.finishedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
|
||||
{j.logTail && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">
|
||||
Log tail
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-black/40 p-3 text-xs text-zinc-400 whitespace-pre-wrap">
|
||||
{j.logTail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{e.renderJobs.length === 0 && (
|
||||
<div className="p-4 text-sm text-zinc-500">No attempts yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event timeline */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Timeline</div>
|
||||
<ol className="p-4 space-y-2">
|
||||
{e.events.map((ev) => (
|
||||
<li key={ev.id} className="flex items-start gap-3 text-sm">
|
||||
<span className="text-xs text-zinc-500 w-40 shrink-0 whitespace-nowrap">
|
||||
{new Date(ev.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<span className="text-zinc-300">
|
||||
{ev.fromStatus ? `${statusLabel(ev.fromStatus)} → ` : ""}
|
||||
{statusLabel(ev.toStatus)}
|
||||
<span className="text-zinc-500"> · {ev.actorType.toLowerCase()}</span>
|
||||
{ev.note && <span className="block text-xs text-zinc-500">{ev.note}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ExportDetailClient } from "./ExportDetailClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ExportDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ exportId: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
const { exportId } = await params;
|
||||
return <ExportDetailClient exportId={exportId} />;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { ArrowLeft, Zap, Power, Cpu, HardDrive, MemoryStick } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MachineRow {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
enabled: boolean;
|
||||
status: "ONLINE" | "OFFLINE" | "DISABLED";
|
||||
lastSeenAt: string | null;
|
||||
workerVersion: string | null;
|
||||
aeVersion: string | null;
|
||||
availability: { mode?: string } | null;
|
||||
renderNowUntil: string | null;
|
||||
latestHeartbeat: {
|
||||
createdAt: string;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
diskFreeGb: number | null;
|
||||
} | null;
|
||||
currentJob: {
|
||||
id: string;
|
||||
exportId: string | null;
|
||||
shotCode: string | null;
|
||||
versionString: string | null;
|
||||
progress: number;
|
||||
etaSeconds: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
ONLINE: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
OFFLINE: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
DISABLED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
export function MachinesClient() {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["pipeline-machines"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/machines");
|
||||
if (!res.ok) throw new Error("Failed to load machines");
|
||||
return res.json() as Promise<{ machines: MachineRow[] }>;
|
||||
},
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
async function patch(machineId: string, body: Record<string, unknown>, okMsg: string) {
|
||||
const res = await fetch(`/api/machines/${machineId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const resBody = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: "Update failed", description: resBody.error ?? res.statusText, variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: okMsg });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-machines"] });
|
||||
}
|
||||
|
||||
const machines = data?.machines ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pipeline">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Render Machines</h1>
|
||||
<p className="text-sm text-zinc-400 mt-1">
|
||||
Worker status, render windows and the Render Now override
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-zinc-500">Loading…</div>
|
||||
) : machines.length === 0 ? (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-10 text-center text-zinc-500">
|
||||
No machines registered yet — install the RenderWorker service on a workstation and it will
|
||||
appear here after its first registration.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{machines.map((m) => {
|
||||
const renderNowActive = m.renderNowUntil && new Date(m.renderNowUntil) > new Date();
|
||||
return (
|
||||
<div key={m.id} className="rounded-lg border border-zinc-800 bg-zinc-900 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-medium">{m.name}</div>
|
||||
<div className="text-xs text-zinc-500">{m.hostname}</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn("rounded-full border px-2 py-0.5 text-xs", STATUS_STYLES[m.status])}
|
||||
>
|
||||
{m.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500 space-y-0.5">
|
||||
<div>Worker {m.workerVersion ?? "?"} · AE {m.aeVersion ?? "?"}</div>
|
||||
<div>
|
||||
Availability: {m.availability?.mode ?? "ALWAYS"}
|
||||
{renderNowActive && (
|
||||
<span className="text-amber-400">
|
||||
{" "}· Render Now until {new Date(m.renderNowUntil!).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.lastSeenAt && <div>Last seen {new Date(m.lastSeenAt).toLocaleString()}</div>}
|
||||
</div>
|
||||
|
||||
{m.latestHeartbeat && (
|
||||
<div className="flex items-center gap-4 text-xs text-zinc-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
{m.latestHeartbeat.cpuPercent != null ? `${Math.round(m.latestHeartbeat.cpuPercent)}%` : "—"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MemoryStick className="h-3.5 w-3.5" />
|
||||
{m.latestHeartbeat.memPercent != null ? `${Math.round(m.latestHeartbeat.memPercent)}%` : "—"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
{m.latestHeartbeat.diskFreeGb != null ? `${Math.round(m.latestHeartbeat.diskFreeGb)} GB free` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{m.currentJob ? (
|
||||
<div className="rounded bg-zinc-800/60 p-2.5 space-y-1.5">
|
||||
<div className="text-xs text-zinc-300">
|
||||
Rendering{" "}
|
||||
{m.currentJob.exportId ? (
|
||||
<Link
|
||||
href={`/pipeline/exports/${m.currentJob.exportId}`}
|
||||
className="text-amber-400 hover:underline"
|
||||
>
|
||||
{m.currentJob.shotCode} {m.currentJob.versionString}
|
||||
</Link>
|
||||
) : (
|
||||
"job"
|
||||
)}
|
||||
</div>
|
||||
<Progress value={m.currentJob.progress * 100} className="h-1.5" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-zinc-600">Idle</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!m.enabled}
|
||||
onClick={() =>
|
||||
patch(
|
||||
m.id,
|
||||
{ renderNowHours: renderNowActive ? 0 : 4 },
|
||||
renderNowActive ? "Render Now cleared" : "Render Now active for 4 h"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Zap className={cn("h-4 w-4 mr-1.5", renderNowActive ? "text-amber-400" : "")} />
|
||||
{renderNowActive ? "Stop Render Now" : "Render Now (4 h)"}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
patch(m.id, { enabled: !m.enabled }, m.enabled ? "Machine disabled" : "Machine enabled")
|
||||
}
|
||||
>
|
||||
<Power className={cn("h-4 w-4 mr-1.5", m.enabled ? "text-red-400" : "text-green-400")} />
|
||||
{m.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToolVersions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolRow {
|
||||
id: string;
|
||||
tool: string;
|
||||
toolLabel: string;
|
||||
machineName: string;
|
||||
version: string;
|
||||
buildRef: string | null;
|
||||
hostUser: string | null;
|
||||
hostApp: string | null;
|
||||
lastSeenAt: string;
|
||||
newestVersion: string;
|
||||
outdated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is deployed where. Each tool reports its own version on startup, and
|
||||
* anything behind the newest version seen for that tool is flagged — so
|
||||
* updating one machine is what surfaces the ones still to do.
|
||||
*/
|
||||
function ToolVersions() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["tool-versions"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/tools");
|
||||
if (!res.ok) throw new Error("Failed to load tool versions");
|
||||
return res.json() as Promise<{ tools: ToolRow[] }>;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const tools = data?.tools ?? [];
|
||||
const stale = tools.filter((t) => t.outdated).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white flex items-center gap-3">
|
||||
Deployed tool versions
|
||||
{stale > 0 && (
|
||||
<span className="text-xs text-amber-400">
|
||||
{stale} machine{stale > 1 ? "s" : ""} behind
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{tools.map((t) => (
|
||||
<div key={t.id} className="px-4 py-2.5 text-sm flex items-center gap-3 flex-wrap">
|
||||
<span className="w-44 shrink-0 text-zinc-300">{t.toolLabel}</span>
|
||||
<span className="w-40 shrink-0 text-zinc-400 text-xs font-mono">{t.machineName}</span>
|
||||
<span className={cn("text-xs font-mono", t.outdated ? "text-amber-400" : "text-emerald-400")}>
|
||||
v{t.version}
|
||||
</span>
|
||||
{t.outdated && <span className="text-xs text-amber-400/80">→ rebuild to v{t.newestVersion}</span>}
|
||||
{t.buildRef && <span className="text-xs text-zinc-600 font-mono">{t.buildRef}</span>}
|
||||
{t.hostUser && <span className="text-xs text-zinc-600">{t.hostUser}</span>}
|
||||
<span className="ml-auto text-xs text-zinc-600">
|
||||
seen {new Date(t.lastSeenAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{tools.length === 0 && (
|
||||
<div className="p-4 text-sm text-zinc-500">
|
||||
Nothing has reported yet. Versions appear as each panel and worker starts up.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { MachinesClient } from "./MachinesClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MachinesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
return <MachinesClient />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { PipelineQueueClient } from "./PipelineQueueClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PipelinePage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||
select: { id: true, name: true, code: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return <PipelineQueueClient projects={projects} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Shared status→style maps for the pipeline pages. */
|
||||
|
||||
export const EXPORT_STATUS_STYLES: Record<string, string> = {
|
||||
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||||
RENDERING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
RENDER_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
VALIDATING: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
||||
VALIDATION_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
GENERATING_PREVIEW: "bg-indigo-500/15 text-indigo-400 border-indigo-500/30",
|
||||
PREVIEW_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
READY_FOR_QC: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
QC_FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
READY_FOR_DELIVERY: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
PACKAGED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
DELIVERED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
|
||||
SUPERSEDED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
|
||||
ARCHIVED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
|
||||
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
|
||||
};
|
||||
|
||||
export const DELIVERY_STATUS_STYLES: Record<string, string> = {
|
||||
DRAFT: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||||
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||||
BUILDING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
READY: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
DELIVERED: "bg-emerald-500/15 text-emerald-300 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",
|
||||
};
|
||||
|
||||
export const FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"];
|
||||
export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"];
|
||||
|
||||
export function statusLabel(status: string): string {
|
||||
return status.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number | null | undefined): string {
|
||||
if (seconds == null || seconds <= 0) return "—";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
@@ -58,6 +58,14 @@ interface ProjectTabsClientProps {
|
||||
startDate: Date | null;
|
||||
slackWebhook: string | null;
|
||||
slackChannel: string | null;
|
||||
pipelineConfig: {
|
||||
templateAep?: string | null;
|
||||
rendersRoot?: string | null;
|
||||
overlayComp?: string | null;
|
||||
previewComp?: string | null;
|
||||
lutComp?: string | null;
|
||||
autoShotThumbnail?: boolean | null;
|
||||
} | null;
|
||||
};
|
||||
clients: Client[];
|
||||
teamMembers: TeamMember[];
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
FilePlus2,
|
||||
Pencil,
|
||||
Film,
|
||||
FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { parseEdlCsv, parsePictureTrackerCsv } from "@/lib/edl-utils";
|
||||
import type { EdlImportRow, PictureTrackerRow } from "@/lib/edl-utils";
|
||||
import { importShotsFromEdl, updateShotsSeqTimecodes } from "@/actions/shots";
|
||||
import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv, parseGroupAssignCsv } from "@/lib/edl-utils";
|
||||
import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow, GroupAssignRow } from "@/lib/edl-utils";
|
||||
import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv, assignShotGroups } from "@/actions/shots";
|
||||
|
||||
interface EdlImportClientProps {
|
||||
projectId: string;
|
||||
@@ -54,6 +55,24 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||
|
||||
// ── Simple CSV state ────────────────────────────────────────────────────────
|
||||
const scFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [scStep, setScStep] = useState<"input" | "preview" | "result">("input");
|
||||
const [scCsvText, setScCsvText] = useState("");
|
||||
const [scRows, setScRows] = useState<SimpleCsvRow[]>([]);
|
||||
const [scErrors, setScErrors] = useState<string[]>([]);
|
||||
const [scImporting, setScImporting] = useState(false);
|
||||
const [scResult, setScResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||
|
||||
// ── Group Assignment state ───────────────────────────────────────────────
|
||||
const gaFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [gaStep, setGaStep] = useState<"input" | "preview" | "result">("input");
|
||||
const [gaCsvText, setGaCsvText] = useState("");
|
||||
const [gaRows, setGaRows] = useState<GroupAssignRow[]>([]);
|
||||
const [gaErrors, setGaErrors] = useState<string[]>([]);
|
||||
const [gaAssigning, setGaAssigning] = useState(false);
|
||||
const [gaResult, setGaResult] = useState<{ assigned: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||
|
||||
// ── Picture Tracker state ─────────────────────────────────────────────────
|
||||
const ptFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
|
||||
@@ -128,6 +147,102 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
// ── Simple CSV handlers ─────────────────────────────────────────────────────
|
||||
const handleScFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setScCsvText(ev.target?.result as string ?? "");
|
||||
reader.readAsText(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleScParse = useCallback(() => {
|
||||
const { rows: parsed, errors } = parseSimpleCsv(scCsvText, existingShotCodes);
|
||||
setScErrors(errors);
|
||||
setScRows(parsed);
|
||||
if (parsed.length > 0) setScStep("preview");
|
||||
}, [scCsvText, existingShotCodes]);
|
||||
|
||||
const toggleScAction = (idx: number) => {
|
||||
setScRows((prev) =>
|
||||
prev.map((r, i) => {
|
||||
if (i !== idx) return r;
|
||||
const cycle: SimpleCsvRow["action"][] = ["create", "update", "skip"];
|
||||
const next = cycle[(cycle.indexOf(r.action) + 1) % cycle.length];
|
||||
return { ...r, action: next };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleScImport = async () => {
|
||||
setScImporting(true);
|
||||
try {
|
||||
const res = await importShotsFromSimpleCsv(projectId, scRows);
|
||||
setScResult(res);
|
||||
setScStep("result");
|
||||
if (res.created.length + res.updated.length > 0) {
|
||||
toast({
|
||||
title: "Import complete",
|
||||
description: `${res.created.length} created, ${res.updated.length} updated`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setScImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScReset = () => {
|
||||
setScStep("input");
|
||||
setScCsvText("");
|
||||
setScRows([]);
|
||||
setScErrors([]);
|
||||
setScResult(null);
|
||||
};
|
||||
|
||||
// ── Group Assignment handlers ────────────────────────────────────────────
|
||||
const handleGaFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setGaCsvText(ev.target?.result as string ?? "");
|
||||
reader.readAsText(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleGaParse = useCallback(() => {
|
||||
const { rows: parsed, errors } = parseGroupAssignCsv(gaCsvText, existingShotCodes);
|
||||
setGaErrors(errors);
|
||||
setGaRows(parsed);
|
||||
if (parsed.length > 0) setGaStep("preview");
|
||||
}, [gaCsvText, existingShotCodes]);
|
||||
|
||||
const handleGaAssign = async () => {
|
||||
setGaAssigning(true);
|
||||
try {
|
||||
const res = await assignShotGroups(projectId, gaRows);
|
||||
setGaResult(res);
|
||||
setGaStep("result");
|
||||
if (res.assigned.length > 0) {
|
||||
toast({ title: "Groups assigned", description: `${res.assigned.length} shot${res.assigned.length !== 1 ? "s" : ""} updated` });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: "Assignment failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setGaAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGaReset = () => {
|
||||
setGaStep("input");
|
||||
setGaCsvText("");
|
||||
setGaRows([]);
|
||||
setGaErrors([]);
|
||||
setGaResult(null);
|
||||
};
|
||||
|
||||
// ── Picture Tracker handlers ──────────────────────────────────────────────
|
||||
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -426,6 +541,355 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── SIMPLE CSV PANEL ──────────────────────────────────────────── */}
|
||||
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Import Shots from Simple CSV</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
Create or update shots from a CSV with <span className="font-mono text-zinc-300">Shot Name</span>, <span className="font-mono text-zinc-300">Time Code In</span>, <span className="font-mono text-zinc-300">Time Code Out</span>, <span className="font-mono text-zinc-300">Description</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scStep === "input" && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input ref={scFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleScFileUpload} />
|
||||
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => scFileInputRef.current?.click()}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Upload .csv
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={scCsvText}
|
||||
onChange={(e) => setScCsvText(e.target.value)}
|
||||
placeholder={`Shot Name,Time Code In,Time Code Out,Description\nUNG_108_001_010,01:00:10:00,01:00:20:00,Hero wide shot`}
|
||||
className="font-mono text-xs min-h-[200px] bg-zinc-950 border-zinc-700 resize-y"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
|
||||
<p className="font-medium text-zinc-300">Expected format</p>
|
||||
<p>Required: <span className="font-mono text-amber-400">Shot Name</span></p>
|
||||
<p>Optional: <span className="font-mono text-zinc-400">Time Code In, Time Code Out, Description</span></p>
|
||||
<p>Existing shots will be set to <span className="font-mono text-blue-400">update</span>; new shots to <span className="font-mono text-emerald-400">create</span>. Toggle per row in the preview.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleScParse} disabled={!scCsvText.trim()} className="gap-2">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Parse & Preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scStep === "preview" && (() => {
|
||||
const scCreate = scRows.filter((r) => r.action === "create").length;
|
||||
const scUpdate = scRows.filter((r) => r.action === "update").length;
|
||||
const scSkip = scRows.filter((r) => r.action === "skip").length;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
|
||||
<FilePlus2 className="h-3.5 w-3.5" />
|
||||
{scCreate} to create
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{scUpdate} to update
|
||||
</div>
|
||||
{scSkip > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
{scSkip} to skip
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scErrors.length > 0 && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5" /> {scErrors.length} parse warning{scErrors.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{scErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
|
||||
<span>Action</span>
|
||||
<span>Shot Code</span>
|
||||
<span>Seq TC In</span>
|
||||
<span>Seq TC Out</span>
|
||||
<span>Group</span>
|
||||
<span>Description</span>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
|
||||
{scRows.map((row, i) => {
|
||||
const ActionIcon = ACTION_ICONS[row.action];
|
||||
return (
|
||||
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] items-center gap-4 px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => toggleScAction(i)}
|
||||
title="Click to cycle: create → update → skip"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs font-medium transition-colors shrink-0",
|
||||
ACTION_STYLES[row.action]
|
||||
)}
|
||||
>
|
||||
<ActionIcon className="h-3 w-3" />
|
||||
{row.action}
|
||||
</button>
|
||||
<span className="font-mono text-xs text-zinc-200 truncate">{row.shotCode}</span>
|
||||
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeStart || "—"}</span>
|
||||
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeEnd || "—"}</span>
|
||||
<span className="text-xs text-zinc-300 truncate">{row.group || <span className="text-zinc-600">none</span>}</span>
|
||||
<span className="text-xs text-zinc-500 truncate">{row.description || "—"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleScReset}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleScImport}
|
||||
disabled={scImporting || (scCreate + scUpdate === 0)}
|
||||
className="gap-2"
|
||||
>
|
||||
{scImporting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{scImporting ? "Importing…" : `Import ${scCreate + scUpdate} shot${scCreate + scUpdate !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{scStep === "result" && scResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||
Import complete
|
||||
</h2>
|
||||
{scResult.created.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-emerald-400 uppercase tracking-wide">Created ({scResult.created.length})</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scResult.created.map((c) => (
|
||||
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scResult.updated.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Updated ({scResult.updated.length})</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scResult.updated.map((c) => (
|
||||
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scResult.skipped.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped ({scResult.skipped.length})</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scResult.skipped.map((c) => (
|
||||
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5" /> Errors ({scResult.errors.length})
|
||||
</p>
|
||||
{scResult.errors.map((e, i) => (
|
||||
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={handleScReset}>Import Another</Button>
|
||||
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── GROUP ASSIGNMENT PANEL ─────────────────────────────────────── */}
|
||||
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Assign Shots to Groups</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
Bulk-assign existing shots to groups from a CSV with <span className="font-mono text-zinc-300">Shot Name</span> and <span className="font-mono text-zinc-300">Group</span> columns. Groups are created automatically if they don’t exist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{gaStep === "input" && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input ref={gaFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleGaFileUpload} />
|
||||
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => gaFileInputRef.current?.click()}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Upload .csv
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={gaCsvText}
|
||||
onChange={(e) => setGaCsvText(e.target.value)}
|
||||
placeholder={`Shot Name,Group\nUNG_108_001_010,Action Sequences\nUNG_108_002_020,Compositing`}
|
||||
className="font-mono text-xs min-h-[180px] bg-zinc-950 border-zinc-700 resize-y"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
|
||||
<p className="font-medium text-zinc-300">Expected format</p>
|
||||
<p>Required: <span className="font-mono text-amber-400">Shot Name</span>, <span className="font-mono text-amber-400">Group</span></p>
|
||||
<p>Only shots that already exist in this project are updated. Unknown shots are skipped.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleGaParse} disabled={!gaCsvText.trim()} className="gap-2">
|
||||
<Film className="h-4 w-4" />
|
||||
Parse & Preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gaStep === "preview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{gaRows.filter((r) => r.exists).length} to assign
|
||||
</div>
|
||||
{gaRows.filter((r) => !r.exists).length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
{gaRows.filter((r) => !r.exists).length} not found (will skip)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gaErrors.length > 0 && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5" /> {gaErrors.length} parse warning{gaErrors.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{gaErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
|
||||
<span>Shot Code</span>
|
||||
<span>Group</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
|
||||
{gaRows.map((row, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1fr_auto] items-center gap-4 px-4 py-3 text-sm">
|
||||
<span className="font-mono text-xs text-zinc-200">{row.shotCode}</span>
|
||||
<span className="text-xs text-zinc-300">{row.groupName}</span>
|
||||
{row.exists ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-blue-500/10 text-blue-400 border-blue-500/20">
|
||||
<Pencil className="h-3 w-3" /> assign
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-zinc-500/10 text-zinc-400 border-zinc-500/20">
|
||||
<SkipForward className="h-3 w-3" /> skip
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleGaReset}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGaAssign}
|
||||
disabled={gaAssigning || gaRows.filter((r) => r.exists).length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{gaAssigning ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{gaAssigning
|
||||
? "Assigning…"
|
||||
: `Assign ${gaRows.filter((r) => r.exists).length} shot${gaRows.filter((r) => r.exists).length !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gaStep === "result" && gaResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
|
||||
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||
Group assignment complete
|
||||
</h2>
|
||||
{gaResult.assigned.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Assigned ({gaResult.assigned.length})</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gaResult.assigned.map((c) => (
|
||||
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{gaResult.skipped.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped / not found ({gaResult.skipped.length})</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gaResult.skipped.map((c) => (
|
||||
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{gaResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5" /> Errors ({gaResult.errors.length})
|
||||
</p>
|
||||
{gaResult.errors.map((e, i) => (
|
||||
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" onClick={handleGaReset}>Import Another</Button>
|
||||
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */}
|
||||
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default async function KanbanPage({
|
||||
where: { projectId: id },
|
||||
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
||||
include: {
|
||||
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
|
||||
shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
assignedArtist: {
|
||||
select: { id: true, name: true, email: true, image: true },
|
||||
|
||||
@@ -60,7 +60,7 @@ async function getProject(id: string) {
|
||||
tasks: {
|
||||
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
||||
include: {
|
||||
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
|
||||
shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||
_count: { select: { versions: true } },
|
||||
@@ -201,6 +201,14 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
||||
startDate: project.startDate,
|
||||
slackWebhook: project.slackWebhook,
|
||||
slackChannel: project.slackChannel,
|
||||
pipelineConfig: project.pipelineConfig as {
|
||||
templateAep?: string | null;
|
||||
rendersRoot?: string | null;
|
||||
overlayComp?: string | null;
|
||||
previewComp?: string | null;
|
||||
lutComp?: string | null;
|
||||
autoShotThumbnail?: boolean | null;
|
||||
} | null,
|
||||
}}
|
||||
clients={clients}
|
||||
teamMembers={teamMembers}
|
||||
|
||||
@@ -30,10 +30,13 @@ import {
|
||||
FileVideo,
|
||||
Pencil,
|
||||
Check,
|
||||
LayoutList,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||
import { ShotExportsTab } from "@/components/shots/ShotExportsTab";
|
||||
import { ShotSlateTab } from "@/components/shots/ShotSlateTab";
|
||||
import { FootageViewer } from "@/components/shots/FootageViewer";
|
||||
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
|
||||
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
|
||||
@@ -73,6 +76,7 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
|
||||
};
|
||||
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
|
||||
export default function ShotDetailPage() {
|
||||
const params = useParams<{ id: string; shotId: string }>();
|
||||
@@ -89,7 +93,7 @@ export default function ShotDetailPage() {
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isActioning, setIsActioning] = useState(false);
|
||||
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks");
|
||||
const [activeTab, setActiveTab] = useState<"tasks" | "slate" | "reviews" | "footage" | "exports" | "settings">("tasks");
|
||||
const [editingVersion, setEditingVersion] = useState(false);
|
||||
const [versionInput, setVersionInput] = useState("");
|
||||
const [savingVersion, setSavingVersion] = useState(false);
|
||||
@@ -248,11 +252,11 @@ export default function ShotDetailPage() {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{shot.thumbnailUrl && (
|
||||
{shotThumbnail(shot) && (
|
||||
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
|
||||
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
src={shotThumbnail(shot)!}
|
||||
alt={shot.shotCode}
|
||||
fill
|
||||
className="object-cover"
|
||||
@@ -519,6 +523,18 @@ export default function ShotDetailPage() {
|
||||
<ListTodo className="h-4 w-4" />
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("slate")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "slate"
|
||||
? "border-amber-500 text-amber-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
<LayoutList className="h-4 w-4" />
|
||||
Slate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("reviews")}
|
||||
className={cn(
|
||||
@@ -543,6 +559,18 @@ export default function ShotDetailPage() {
|
||||
<Video className="h-4 w-4" />
|
||||
Footage
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("exports")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === "exports"
|
||||
? "border-amber-500 text-amber-400"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
Exports
|
||||
</button>
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => setActiveTab("settings")}
|
||||
@@ -570,6 +598,10 @@ export default function ShotDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "slate" && (
|
||||
<ShotSlateTab shot={shot} projectName={projectName} onSaved={fetchShot} />
|
||||
)}
|
||||
|
||||
{activeTab === "reviews" && (
|
||||
<div className="space-y-3">
|
||||
{tasks.length === 0 ? (
|
||||
@@ -658,6 +690,8 @@ export default function ShotDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "exports" && <ShotExportsTab shotId={shot.id} />}
|
||||
|
||||
{activeTab === "settings" && canManage && (
|
||||
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
|
||||
)}
|
||||
|
||||
@@ -7,10 +7,16 @@ import { Input } from "@/components/ui/input";
|
||||
import { ProjectCard } from "@/components/projects/ProjectCard";
|
||||
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
|
||||
import { Plus, Search, Loader2 } from "lucide-react";
|
||||
import { HIDE_ARCHIVED_KEY } from "@/components/settings/HideArchivedToggle";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [hideArchived, setHideArchived] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHideArchived(localStorage.getItem(HIDE_ARCHIVED_KEY) === "1");
|
||||
}, []);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["projects", search],
|
||||
@@ -35,6 +41,8 @@ export default function ProjectsPage() {
|
||||
const projects = data?.projects ?? [];
|
||||
const clients = clientsData?.clients ?? [];
|
||||
|
||||
const visibleProjects = hideArchived ? projects.filter((p: any) => p.status !== "ARCHIVED") : projects;
|
||||
|
||||
const SCROLL_KEY = 'projects-scroll';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,6 +64,9 @@ export default function ProjectsPage() {
|
||||
<h1 className="text-3xl font-bold text-white">Projects</h1>
|
||||
<p className="text-zinc-400 mt-1">
|
||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||
{hideArchived && projects.length !== visibleProjects.length && (
|
||||
<span className="text-zinc-600 ml-1">({projects.length - visibleProjects.length} archived hidden)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowNew(true)} className="gap-2">
|
||||
@@ -94,7 +105,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{projects.map((project) => (
|
||||
{visibleProjects.map((project: any) => (
|
||||
<div key={project.id} onClick={saveScroll}>
|
||||
<ProjectCard project={project} />
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,12 @@ export interface ScheduleTask {
|
||||
email: string;
|
||||
image: string | null;
|
||||
} | null;
|
||||
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
thumbnailUrl: string | null;
|
||||
generatedThumbnailUrl: string | null;
|
||||
} | null;
|
||||
asset: { id: string; assetCode: string; name: string } | null;
|
||||
project: { id: string; name: string; code: string };
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default async function SchedulePage({
|
||||
...(artist ? { assignedArtistId: artist } : {}),
|
||||
},
|
||||
include: {
|
||||
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
|
||||
shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
assignedArtist: {
|
||||
@@ -49,7 +49,7 @@ export default async function SchedulePage({
|
||||
...(artist ? { assignedArtistId: artist } : {}),
|
||||
},
|
||||
include: {
|
||||
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
|
||||
shot: { select: { id: true, shotCode: true, thumbnailUrl: true, generatedThumbnailUrl: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
assignedArtist: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getInitials } from "@/lib/utils";
|
||||
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
|
||||
import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm";
|
||||
import { SketchTemplatesSection } from "@/components/settings/SketchTemplatesSection";
|
||||
import { HideArchivedToggle } from "@/components/settings/HideArchivedToggle";
|
||||
import { getHetznerConfig } from "@/actions/settings";
|
||||
|
||||
export const metadata = { title: "Settings" };
|
||||
@@ -42,6 +43,15 @@ export default async function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Display Preferences</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<HideArchivedToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
|
||||
|
||||
{isAdmin && hetznerConfig && (
|
||||
|
||||
@@ -18,11 +18,19 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { updateShotNotes, bulkUpdateDueDates, toggleKeyShot } from "@/actions/shots";
|
||||
import { updateShotNotes, bulkUpdateDueDates, bulkDeleteShots, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
|
||||
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Film,
|
||||
@@ -38,8 +46,13 @@ import {
|
||||
ShieldCheck,
|
||||
Eye,
|
||||
Star,
|
||||
Trash2,
|
||||
ChevronsUpDown,
|
||||
Clapperboard,
|
||||
Scaling,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
|
||||
type ShotRow = {
|
||||
id: string;
|
||||
@@ -51,10 +64,14 @@ type ShotRow = {
|
||||
priority: string;
|
||||
dueDate: Date | string | null;
|
||||
thumbnailUrl: string | null;
|
||||
generatedThumbnailUrl: string | null;
|
||||
notes: string | null;
|
||||
description: string | null;
|
||||
isKeyShot: boolean;
|
||||
isScaled: boolean;
|
||||
shotVersion: string | null;
|
||||
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||
shotGroup: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
interface Project {
|
||||
@@ -208,6 +225,203 @@ function BulkDueDateDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function BulkDeleteDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
count,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
count: number;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
startTransition(async () => {
|
||||
await onConfirm();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-400">Delete {count} shot{count !== 1 ? "s" : ""}?</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-zinc-300">
|
||||
This will permanently delete <span className="font-semibold text-white">{count}</span>{" "}
|
||||
shot{count !== 1 ? "s" : ""}, including all tasks, versions, footage, and annotations.
|
||||
This cannot be undone.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
A shot with renders or deliveries already on record is protected and will be skipped
|
||||
rather than deleted — you'll see which ones and why.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
{isPending ? "Deleting…" : `Yes, delete ${count} shot${count !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MovReviewDialog ───────────────────────────────────────────────────────────
|
||||
|
||||
type ReviewPreview = Awaited<ReturnType<typeof previewMovReview>>;
|
||||
|
||||
/**
|
||||
* Confirm what a MOV review package would contain before building it
|
||||
* (RenderPipeline2 §11.6).
|
||||
*
|
||||
* The version each shot resolves to is the newest one that actually has a
|
||||
* preview MOV, which is not always the newest one full stop — so the dialog
|
||||
* names the version per shot rather than making anyone guess, and lists the
|
||||
* shots with no MOV at all instead of quietly building a smaller folder.
|
||||
*/
|
||||
function MovReviewDialog({
|
||||
open,
|
||||
onClose,
|
||||
onBuilt,
|
||||
shotIds,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onBuilt: () => void;
|
||||
shotIds: string[];
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [preview, setPreview] = useState<ReviewPreview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
previewMovReview(shotIds)
|
||||
.then((result) => {
|
||||
if (!cancelled) setPreview(result);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const ready = preview?.ok ? preview.candidates : [];
|
||||
const skipped = preview?.ok ? preview.skipped : [];
|
||||
|
||||
const handleBuild = () => {
|
||||
startTransition(async () => {
|
||||
const result = await buildMovReview(shotIds);
|
||||
if (!result.ok) {
|
||||
toast({ title: "Could not build review package", description: result.error, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: `${result.package.name} queued`,
|
||||
description: `${result.package.itemCount} MOV${result.package.itemCount !== 1 ? "s" : ""} → ${result.package.rootPath}`,
|
||||
});
|
||||
onBuilt();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Build MOV review package</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<p className="text-sm text-zinc-400 flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Working out what each shot would contribute…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && preview && !preview.ok && (
|
||||
<p className="text-sm text-red-400">{preview.error}</p>
|
||||
)}
|
||||
|
||||
{!loading && preview?.ok && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/60 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">Folder</div>
|
||||
<div className="text-sm text-white font-medium break-all">{preview.packageName}</div>
|
||||
<div className="text-xs text-zinc-500 break-all">{preview.packageDir}</div>
|
||||
</div>
|
||||
|
||||
{ready.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-zinc-400">
|
||||
{ready.length} MOV{ready.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto rounded-md border border-zinc-800 divide-y divide-zinc-800/60">
|
||||
{ready.map((c) => (
|
||||
<div key={c.shotId} className="flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
<span className="text-zinc-200">{c.shotCode}</span>
|
||||
<span className="text-zinc-500">{c.versionString}</span>
|
||||
<span className="ml-auto text-zinc-600 truncate">{c.deliveredName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skipped.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-amber-400">
|
||||
{skipped.length} shot{skipped.length !== 1 ? "s" : ""} left out
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border border-amber-500/20 bg-amber-500/5 divide-y divide-amber-500/10">
|
||||
{skipped.map((sk) => (
|
||||
<div key={sk.shotId} className="flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
<span className="text-zinc-300">{sk.shotCode}</span>
|
||||
<span className="text-amber-400/80">{sk.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-zinc-500">
|
||||
A render machine builds the folder. Nothing about these shots changes — a review is
|
||||
not a delivery, so the versions stay exactly where they are in the pipeline.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleBuild} disabled={isPending || loading || ready.length === 0}>
|
||||
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
{isPending ? "Queueing…" : `Build with ${ready.length} MOV${ready.length !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NotesCell ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesCell({
|
||||
@@ -263,10 +477,30 @@ function NotesCell({
|
||||
);
|
||||
}
|
||||
|
||||
// ── KeyShotToggle ─────────────────────────────────────────────────────────────
|
||||
// ── Shot flag toggles ─────────────────────────────────────────────────────────
|
||||
|
||||
function KeyShotToggle({ shot }: { shot: ShotRow }) {
|
||||
const [optimistic, setOptimistic] = useState(shot.isKeyShot);
|
||||
/**
|
||||
* A standing yes/no property of a shot, flipped straight from the board.
|
||||
* Optimistic: the switch moves immediately and rolls back if the save fails,
|
||||
* because a producer ticking twenty shots should not wait on a round trip each
|
||||
* time.
|
||||
*/
|
||||
function FlagToggle({
|
||||
value,
|
||||
onSet,
|
||||
onLabel,
|
||||
offLabel,
|
||||
failureTitle,
|
||||
tone,
|
||||
}: {
|
||||
value: boolean;
|
||||
onSet: (next: boolean) => Promise<unknown>;
|
||||
onLabel: string;
|
||||
offLabel: string;
|
||||
failureTitle: string;
|
||||
tone: { on: string; ring: string };
|
||||
}) {
|
||||
const [optimistic, setOptimistic] = useState(value);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -275,10 +509,10 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) {
|
||||
setOptimistic(next);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await toggleKeyShot(shot.id, next);
|
||||
await onSet(next);
|
||||
} catch {
|
||||
setOptimistic(!next);
|
||||
toast({ title: "Failed to update key shot", variant: "destructive" });
|
||||
toast({ title: failureTitle, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -288,12 +522,13 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) {
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={optimistic}
|
||||
aria-label={optimistic ? "Remove key shot" : "Mark as key shot"}
|
||||
aria-label={optimistic ? offLabel : onLabel}
|
||||
disabled={isPending}
|
||||
onClick={handle}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 disabled:opacity-50",
|
||||
optimistic ? "bg-amber-500" : "bg-zinc-600 hover:bg-zinc-500"
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 disabled:opacity-50",
|
||||
tone.ring,
|
||||
optimistic ? tone.on : "bg-zinc-600 hover:bg-zinc-500"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -306,6 +541,32 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) {
|
||||
);
|
||||
}
|
||||
|
||||
function KeyShotToggle({ shot }: { shot: ShotRow }) {
|
||||
return (
|
||||
<FlagToggle
|
||||
value={shot.isKeyShot}
|
||||
onSet={(next) => toggleKeyShot(shot.id, next)}
|
||||
onLabel="Mark as key shot"
|
||||
offLabel="Remove key shot"
|
||||
failureTitle="Failed to update key shot"
|
||||
tone={{ on: "bg-amber-500", ring: "focus-visible:ring-amber-400" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ScaledShotToggle({ shot }: { shot: ShotRow }) {
|
||||
return (
|
||||
<FlagToggle
|
||||
value={shot.isScaled}
|
||||
onSet={(next) => toggleScaledShot(shot.id, next)}
|
||||
onLabel="Mark as scaled in the edit"
|
||||
offLabel="Remove scaled flag"
|
||||
failureTitle="Failed to update scaled flag"
|
||||
tone={{ on: "bg-cyan-500", ring: "focus-visible:ring-cyan-400" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ShotTable({
|
||||
shots,
|
||||
canManage,
|
||||
@@ -348,6 +609,12 @@ function ShotTable({
|
||||
Key
|
||||
</span>
|
||||
</th>
|
||||
<th className="py-2 px-3 text-xs font-medium text-zinc-500 w-20 text-center">
|
||||
<span className="flex items-center gap-1 justify-center">
|
||||
<Scaling className="h-3 w-3" />
|
||||
Scaled
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
@@ -381,9 +648,9 @@ function ShotTable({
|
||||
{/* Thumbnail */}
|
||||
<td className="py-2 px-3">
|
||||
<div className="w-24 aspect-[2.39/1] rounded overflow-hidden bg-zinc-800 flex items-center justify-center shrink-0">
|
||||
{shot.thumbnailUrl ? (
|
||||
{shotThumbnail(shot) ? (
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
src={shotThumbnail(shot)!}
|
||||
alt={shot.shotCode}
|
||||
width={96}
|
||||
height={40}
|
||||
@@ -408,6 +675,15 @@ function ShotTable({
|
||||
>
|
||||
{shot.shotCode}
|
||||
</Link>
|
||||
{shot.shotVersion && (
|
||||
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
|
||||
)}
|
||||
{shot.isScaled && (
|
||||
<span className="flex items-center gap-1 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-1.5 py-0.5 text-[10px] font-medium text-cyan-300 shrink-0">
|
||||
<Scaling className="h-2.5 w-2.5" />
|
||||
Scaled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{shot.description && (
|
||||
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
|
||||
@@ -450,6 +726,11 @@ function ShotTable({
|
||||
<td className="py-2 px-3 text-center">
|
||||
<KeyShotToggle shot={shot} />
|
||||
</td>
|
||||
|
||||
{/* Scaled-in-edit toggle */}
|
||||
<td className="py-2 px-3 text-center">
|
||||
<ScaledShotToggle shot={shot} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -490,6 +771,9 @@ export function ShotStatusClient({
|
||||
}, []);
|
||||
|
||||
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
|
||||
const [expandedScenes, setExpandedScenes] = useState<Set<string>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load saved episode expand state when project changes
|
||||
@@ -501,8 +785,16 @@ export function ShotStatusClient({
|
||||
} catch {
|
||||
setExpandedEpisodes(new Set());
|
||||
}
|
||||
try {
|
||||
const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupMode`);
|
||||
setGroupMode((savedGroup as "flat" | "scene" | "group") ?? "flat");
|
||||
} catch {
|
||||
setGroupMode("flat");
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
|
||||
|
||||
const toggleEpisode = (ep: string) =>
|
||||
setExpandedEpisodes((prev) => {
|
||||
@@ -516,6 +808,29 @@ export function ShotStatusClient({
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleScene = (sc: string) =>
|
||||
setExpandedScenes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(sc) ? next.delete(sc) : next.add(sc);
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleGroup = (g: string) =>
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(g) ? next.delete(g) : next.add(g);
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleGroupMode = (mode: "flat" | "scene" | "group") => {
|
||||
setGroupMode(mode);
|
||||
if (mode === "scene") setExpandedScenes(new Set(shots.map((s) => s.scene)));
|
||||
if (mode === "group") setExpandedGroups(new Set(shots.map((s) => s.shotGroup?.id ?? "")));
|
||||
if (selectedProjectId) {
|
||||
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectChange = (id: string) => {
|
||||
localStorage.setItem("shotStatus:lastProjectId", id);
|
||||
setSelectedIds(new Set());
|
||||
@@ -553,6 +868,26 @@ export function ShotStatusClient({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
const result = await bulkDeleteShots([...selectedIds]);
|
||||
if (result.failed.length > 0) {
|
||||
toast({
|
||||
title: `Deleted ${result.deleted} shot${result.deleted !== 1 ? "s" : ""}, ${result.failed.length} skipped`,
|
||||
description: result.failed.map((f) => `${f.shotCode}: ${f.reason}`).join("; "),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({ title: `Deleted ${result.deleted} shot${result.deleted !== 1 ? "s" : ""}` });
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
clearSelection();
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({ title: "Failed to delete shots", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
// Group by episode for episodic projects
|
||||
const isEpisodic = selectedProject?.projectType === "EPISODIC";
|
||||
const episodeGroups: [string, ShotRow[]][] = isEpisodic
|
||||
@@ -567,6 +902,31 @@ export function ShotStatusClient({
|
||||
})()
|
||||
: [];
|
||||
|
||||
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
|
||||
? (() => {
|
||||
const map = new Map<string, ShotRow[]>();
|
||||
for (const shot of shots) {
|
||||
const key = shot.scene || "(No Scene)";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(shot);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
})()
|
||||
: [];
|
||||
|
||||
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
|
||||
? (() => {
|
||||
const map = new Map<string, [string, ShotRow[]]>();
|
||||
for (const shot of shots) {
|
||||
const id = shot.shotGroup?.id ?? "";
|
||||
const name = shot.shotGroup?.name ?? "(No Group)";
|
||||
if (!map.has(id)) map.set(id, [name, []]);
|
||||
map.get(id)![1].push(shot);
|
||||
}
|
||||
return Array.from(map.entries()).map(([id, [name, s]]) => [id, name, s] as [string, string, ShotRow[]]);
|
||||
})()
|
||||
: [];
|
||||
|
||||
const episodeDueDateMap = new Map(
|
||||
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
|
||||
);
|
||||
@@ -605,6 +965,25 @@ export function ShotStatusClient({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedProject && !isEpisodic && (
|
||||
<>
|
||||
<span className="text-sm text-zinc-500">Group by:</span>
|
||||
{(["flat", "scene", "group"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleGroupMode(mode)}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded text-xs font-medium transition-colors",
|
||||
groupMode === mode
|
||||
? "bg-zinc-700 text-zinc-100"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{mode === "flat" ? "None" : mode === "scene" ? "Scene" : "Group"}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No project selected */}
|
||||
@@ -645,15 +1024,36 @@ export function ShotStatusClient({
|
||||
<span className="text-sm font-medium text-blue-300">
|
||||
{selectionCount} shot{selectionCount !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 border-blue-500/40 text-blue-300 hover:bg-blue-500/20 hover:text-blue-200"
|
||||
onClick={() => setDueDateDialogOpen(true)}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
Change Due Date
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 border-blue-500/40 text-blue-300 hover:bg-blue-500/20 hover:text-blue-200"
|
||||
>
|
||||
Bulk Actions
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setDueDateDialogOpen(true)}>
|
||||
<CalendarDays className="h-3.5 w-3.5 mr-2" />
|
||||
Change Due Date
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setMovReviewDialogOpen(true)}>
|
||||
<Clapperboard className="h-3.5 w-3.5 mr-2" />
|
||||
Build MOV Review Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="text-red-400 focus:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Delete Shots
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@@ -727,6 +1127,104 @@ export function ShotStatusClient({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : groupMode === "scene" ? (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{sceneGroups.map(([scene, sceneShots]) => {
|
||||
const collapsed = !expandedScenes.has(scene);
|
||||
const sceneIds = sceneShots.map((s) => s.id);
|
||||
const allScSelected = sceneIds.every((id) => selectedIds.has(id));
|
||||
const someScSelected = sceneIds.some((id) => selectedIds.has(id));
|
||||
return (
|
||||
<div key={scene}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
|
||||
{canManage && (
|
||||
<IndeterminateCheckbox
|
||||
checked={allScSelected}
|
||||
indeterminate={someScSelected && !allScSelected}
|
||||
onChange={(e) => toggleMany(sceneIds, e.target.checked)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => toggleScene(scene)}
|
||||
className="flex items-center gap-2 flex-1 text-left"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-white">Scene {scene}</span>
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="overflow-x-auto">
|
||||
<ShotTable
|
||||
shots={sceneShots}
|
||||
canManage={canManage}
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : groupMode === "group" ? (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{shotGroupGroups.map(([groupId, groupName, groupShots]) => {
|
||||
const collapsed = !expandedGroups.has(groupId);
|
||||
const groupIds = groupShots.map((s) => s.id);
|
||||
const allGrSelected = groupIds.every((id) => selectedIds.has(id));
|
||||
const someGrSelected = groupIds.some((id) => selectedIds.has(id));
|
||||
return (
|
||||
<div key={groupId}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
|
||||
{canManage && (
|
||||
<IndeterminateCheckbox
|
||||
checked={allGrSelected}
|
||||
indeterminate={someGrSelected && !allGrSelected}
|
||||
onChange={(e) => toggleMany(groupIds, e.target.checked)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => toggleGroup(groupId)}
|
||||
className="flex items-center gap-2 flex-1 text-left"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-white">{groupName}</span>
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="overflow-x-auto">
|
||||
<ShotTable
|
||||
shots={groupShots}
|
||||
canManage={canManage}
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<ShotTable
|
||||
@@ -750,6 +1248,25 @@ export function ShotStatusClient({
|
||||
onApply={handleBulkDueDate}
|
||||
count={selectionCount}
|
||||
/>
|
||||
|
||||
{/* Bulk delete dialog */}
|
||||
<BulkDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={handleBulkDelete}
|
||||
count={selectionCount}
|
||||
/>
|
||||
|
||||
{/* MOV review package (§11.6) */}
|
||||
<MovReviewDialog
|
||||
open={movReviewDialogOpen}
|
||||
onClose={() => setMovReviewDialogOpen(false)}
|
||||
onBuilt={() => {
|
||||
setMovReviewDialogOpen(false);
|
||||
clearSelection();
|
||||
}}
|
||||
shotIds={[...selectedIds]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,10 +37,16 @@ async function getShotsForProject(projectId: string) {
|
||||
priority: true,
|
||||
dueDate: true,
|
||||
thumbnailUrl: true,
|
||||
generatedThumbnailUrl: true,
|
||||
notes: true,
|
||||
description: true,
|
||||
isKeyShot: true,
|
||||
isScaled: true,
|
||||
// The shot-level version the panel burns into overlays and queueExport
|
||||
// renders under — not the count of review Versions uploaded.
|
||||
shotVersion: true,
|
||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||
shotGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]);
|
||||
|
||||
export interface ThumbnailPreviewItem {
|
||||
fileName: string;
|
||||
stemName: string;
|
||||
shotCode: string | null;
|
||||
shotId: string | null;
|
||||
status: "match" | "no-match";
|
||||
currentThumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
function stem(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
return (dot > 0 ? fileName.slice(0, dot) : fileName).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/batch-upload/thumbnails
|
||||
* Body: { projectId: string; fileNames: string[] }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
let body: { projectId?: string; fileNames?: string[] };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { projectId, fileNames } = body;
|
||||
if (!projectId || !Array.isArray(fileNames)) {
|
||||
return NextResponse.json({ error: "projectId and fileNames are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only image files
|
||||
const imageFiles = fileNames.filter((f) => {
|
||||
const ext = f.split(".").pop()?.toLowerCase() ?? "";
|
||||
return IMAGE_EXTS.has(ext);
|
||||
});
|
||||
|
||||
const shots = await db.shot.findMany({
|
||||
where: { projectId },
|
||||
select: { id: true, shotCode: true, thumbnailUrl: true },
|
||||
});
|
||||
|
||||
// Build a lowercase map for case-insensitive matching
|
||||
const shotMap = new Map(shots.map((s) => [s.shotCode.toLowerCase(), s]));
|
||||
|
||||
const items: ThumbnailPreviewItem[] = fileNames.map((fileName) => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (!IMAGE_EXTS.has(ext)) {
|
||||
return { fileName, stemName: stem(fileName), shotCode: null, shotId: null, status: "no-match" as const, currentThumbnailUrl: null };
|
||||
}
|
||||
const s = stem(fileName);
|
||||
const shot = shotMap.get(s) ?? null;
|
||||
return {
|
||||
fileName,
|
||||
stemName: s,
|
||||
shotCode: shot?.shotCode ?? null,
|
||||
shotId: shot?.id ?? null,
|
||||
status: shot ? "match" : "no-match",
|
||||
currentThumbnailUrl: shot?.thumbnailUrl ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { uploadToHetzner } from "@/lib/storage";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
/**
|
||||
* POST /api/batch-upload/thumbnails/upload
|
||||
* FormData: { projectId, shotId, file (image) }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const shotId = formData.get("shotId") as string | null;
|
||||
const projectId = formData.get("projectId") as string | null;
|
||||
|
||||
if (!file || !shotId || !projectId) {
|
||||
return NextResponse.json({ error: "file, shotId and projectId are required" }, { status: 400 });
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return NextResponse.json({ error: "File must be an image" }, { status: 400 });
|
||||
}
|
||||
|
||||
const shot = await db.shot.findFirst({ where: { id: shotId, projectId }, select: { id: true } });
|
||||
if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const { key } = await uploadToHetzner(buffer, file.name, file.type, "image");
|
||||
const thumbnailUrl = `/api/files/${key}`;
|
||||
|
||||
await db.shot.update({ where: { id: shotId }, data: { thumbnailUrl } });
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
|
||||
return NextResponse.json({ success: true, thumbnailUrl });
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
|
||||
import { recalcShotStatus } from "@/lib/shot-status";
|
||||
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
|
||||
|
||||
export const maxDuration = 300; // 5 min — large .mov high-res files
|
||||
|
||||
@@ -194,8 +194,14 @@ export async function POST(
|
||||
data: { status: "INTERNAL_REVIEW" },
|
||||
});
|
||||
|
||||
// Recalculate the parent shot's status
|
||||
await recalcShotStatus(shotId).catch(() => {});
|
||||
// Recalculate the parent shot's status, clearing any stale sign-off first so
|
||||
// the new version does not land under a shot still reading COMPLETE.
|
||||
await db
|
||||
.$transaction(async (tx) => {
|
||||
await clearStaleShotApproval(tx, shotId);
|
||||
await recalcShotStatus(shotId, tx);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { ApprovalStatus } from "@prisma/client";
|
||||
import { recalcShotStatus } from "@/lib/shot-status";
|
||||
import { validateReviewToken } from "@/lib/review-auth";
|
||||
|
||||
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return existing;
|
||||
return db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: label ?? email.split("@")[0],
|
||||
role: "CLIENT",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
|
||||
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
@@ -48,9 +35,12 @@ export async function POST(
|
||||
}
|
||||
|
||||
if (action === "APPROVE") {
|
||||
await db.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { shotApprovalStatus: "CLIENT_APPROVED" },
|
||||
});
|
||||
await recalcShotStatus(shotId, tx);
|
||||
});
|
||||
} else if (action === "NEEDS_CHANGES") {
|
||||
// Increment shotVersion: v001 → v002, v009 → v010, etc.
|
||||
@@ -59,17 +49,41 @@ export async function POST(
|
||||
const nextNum = verMatch ? parseInt(verMatch[1], 10) + 1 : 2;
|
||||
const nextVersion = "v" + String(nextNum).padStart(3, "0");
|
||||
|
||||
const user = await getOrCreateReviewUser(session, token);
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.shot.update({
|
||||
where: { id: shotId },
|
||||
data: { shotApprovalStatus: "PENDING", sharedWithClient: false, shotVersion: nextVersion },
|
||||
data: { shotVersion: nextVersion },
|
||||
});
|
||||
// Mark all non-DONE tasks as CHANGES
|
||||
await tx.task.updateMany({
|
||||
where: { shotId, status: { not: "DONE" } },
|
||||
data: { status: "CHANGES" },
|
||||
|
||||
// Clears the approval, unshares, marks the tasks needing rework and
|
||||
// recalculates — including the case where every task was already DONE,
|
||||
// which used to match nothing and leave the request with no effect.
|
||||
await markShotForRevisions(tx, shotId);
|
||||
|
||||
// The shot-level verdict has to land on the versions too: the dashboard
|
||||
// counters, the version badges and the client portal's own filters all
|
||||
// read Version.approvalStatus, so a shot-level change request was
|
||||
// invisible everywhere those look.
|
||||
const shown = await tx.version.findMany({
|
||||
where: { task: { shotId }, isClientVisible: true, isLatest: true },
|
||||
select: { id: true },
|
||||
});
|
||||
await recalcShotStatus(shotId, tx);
|
||||
for (const v of shown) {
|
||||
await tx.approval.create({
|
||||
data: {
|
||||
versionId: v.id,
|
||||
userId: user.id,
|
||||
status: "NEEDS_CHANGES",
|
||||
notes: notes ?? "Changes requested on the shot",
|
||||
},
|
||||
});
|
||||
await tx.version.update({
|
||||
where: { id: v.id },
|
||||
data: { approvalStatus: "NEEDS_CHANGES" },
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
@@ -99,8 +113,7 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||
const user = await getOrCreateClientUser(email, session.label);
|
||||
const user = await getOrCreateReviewUser(session, token);
|
||||
|
||||
// Record approval
|
||||
await db.approval.create({
|
||||
@@ -126,9 +139,12 @@ export async function POST(
|
||||
select: { sharedWithClient: true, shotApprovalStatus: true },
|
||||
});
|
||||
if (shot?.sharedWithClient && shot.shotApprovalStatus !== "CLIENT_APPROVED") {
|
||||
// status is left to the recalc below — writing COMPLETE here was
|
||||
// overwritten by it anyway, and disagreed with it whenever another
|
||||
// task on the shot was still on CHANGES.
|
||||
await db.shot.update({
|
||||
where: { id: version.task.shot.id },
|
||||
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
||||
data: { shotApprovalStatus: "CLIENT_APPROVED" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth";
|
||||
|
||||
/**
|
||||
* Edit and delete for notes left through a client review link.
|
||||
*
|
||||
* A reviewer may only touch their own notes: the studio's comments and those of
|
||||
* any other review session are read-only here, whatever the link holder sends.
|
||||
* Ownership is the review session's guest User (lib/review-auth.ts) — the link
|
||||
* is the credential, so everyone holding it shares that identity.
|
||||
*/
|
||||
|
||||
/** Matches the internal addComment/editComment limits (actions/comments.ts). */
|
||||
const MAX_LENGTH = 5000;
|
||||
|
||||
/** The annotation companion comments written by the drawing tools. */
|
||||
const ANNOTATION_PREFIX = "✏️ Annotation at frame";
|
||||
|
||||
type Ctx = { params: Promise<{ token: string; commentId: string }> };
|
||||
|
||||
/**
|
||||
* Resolves the request to a comment the caller is allowed to change, or the
|
||||
* response explaining why not.
|
||||
*
|
||||
* "Not yours" is deliberately answered with 404 rather than 403: a link holder
|
||||
* has no business learning which comment ids exist outside their own notes.
|
||||
*/
|
||||
async function authorizeCommentEdit(req: NextRequest, ctx: Ctx) {
|
||||
const { token, commentId } = await ctx.params;
|
||||
|
||||
const result = await validateReviewToken(token, req);
|
||||
if (result.type === "requiresPassword") {
|
||||
return { error: NextResponse.json({ requiresPassword: true }, { status: 401 }) };
|
||||
}
|
||||
if (result.type === "invalid") {
|
||||
return {
|
||||
error: NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 }),
|
||||
};
|
||||
}
|
||||
const session = result.session;
|
||||
|
||||
const notFound = NextResponse.json({ error: "Comment not found" }, { status: 404 });
|
||||
|
||||
const comment = await db.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
select: {
|
||||
id: true,
|
||||
authorId: true,
|
||||
versionId: true,
|
||||
frameNumber: true,
|
||||
text: true,
|
||||
version: {
|
||||
select: {
|
||||
shot: { select: { projectId: true } },
|
||||
task: { select: { projectId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!comment) return { error: notFound };
|
||||
|
||||
// Same project scoping the rest of the portal applies.
|
||||
const projectId = comment.version.shot?.projectId ?? comment.version.task?.projectId;
|
||||
if (projectId !== session.projectId) return { error: notFound };
|
||||
|
||||
const user = await findReviewUser(session, token);
|
||||
if (!ownsComment(comment, user?.id)) return { error: notFound };
|
||||
|
||||
return { comment };
|
||||
}
|
||||
|
||||
/** PATCH — change the text of one's own note. */
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
const authorized = await authorizeCommentEdit(req, ctx);
|
||||
if (authorized.error) return authorized.error;
|
||||
const { comment } = authorized;
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const text = typeof body?.text === "string" ? body.text.trim() : "";
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: "Note text is required" }, { status: 400 });
|
||||
}
|
||||
if (text.length > MAX_LENGTH) {
|
||||
return NextResponse.json(
|
||||
{ error: `Note must be ${MAX_LENGTH} characters or fewer` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only the text is editable. The frame a note is pinned to is what the studio
|
||||
// works from, so moving it would silently repoint feedback someone may have
|
||||
// already actioned; a note about a different frame is a new note.
|
||||
const updated = await db.comment.update({
|
||||
where: { id: comment.id },
|
||||
data: { text },
|
||||
include: {
|
||||
author: { select: { id: true, name: true, image: true, email: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { author: { select: { id: true, name: true, image: true, email: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ comment: { ...updated, canEdit: true } });
|
||||
}
|
||||
|
||||
/** DELETE — remove one's own note. */
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
const authorized = await authorizeCommentEdit(req, ctx);
|
||||
if (authorized.error) return authorized.error;
|
||||
const { comment } = authorized;
|
||||
|
||||
await db.comment.delete({ where: { id: comment.id } });
|
||||
|
||||
// A drawing leaves a companion comment; deleting the note without the strokes
|
||||
// would leave marks on the frame that nothing explains. Mirrors deleteComment
|
||||
// in actions/comments.ts.
|
||||
if (comment.text.startsWith(ANNOTATION_PREFIX)) {
|
||||
await db.annotation.deleteMany({
|
||||
where: { versionId: comment.versionId, frameNumber: comment.frameNumber },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -1,21 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||
import { validateReviewToken } from "@/lib/review-auth";
|
||||
|
||||
/** Find or create a guest user for the client reviewer based on the session email */
|
||||
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return existing;
|
||||
return db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: label ?? email.split("@")[0],
|
||||
role: "CLIENT",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
@@ -52,8 +38,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
// Resolve commenter identity
|
||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||
const user = await getOrCreateClientUser(email, session.label);
|
||||
const user = await getOrCreateReviewUser(session, token);
|
||||
|
||||
const comment = await db.comment.create({
|
||||
data: {
|
||||
@@ -85,5 +70,6 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ comment }, { status: 201 });
|
||||
// canEdit mirrors the GET shape — a note you just wrote is always your own.
|
||||
return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { validateReviewToken } from "@/lib/review-auth";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code";
|
||||
|
||||
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
|
||||
export async function GET(
|
||||
@@ -43,12 +45,14 @@ export async function GET(
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
clientCode: true,
|
||||
episode: true,
|
||||
sequence: true,
|
||||
description: true,
|
||||
status: true,
|
||||
shotApprovalStatus: true,
|
||||
thumbnailUrl: true,
|
||||
generatedThumbnailUrl: true,
|
||||
tasks: {
|
||||
where: {
|
||||
versions: { some: { isClientVisible: true } },
|
||||
@@ -68,7 +72,6 @@ export async function GET(
|
||||
fps: true,
|
||||
duration: true,
|
||||
thumbnailUrl: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
@@ -100,7 +103,6 @@ export async function GET(
|
||||
fps: true,
|
||||
duration: true,
|
||||
thumbnailUrl: true,
|
||||
notes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
@@ -115,7 +117,22 @@ export async function GET(
|
||||
|
||||
return NextResponse.json({
|
||||
project,
|
||||
shots,
|
||||
// Shots the client sees resolve the same way as internally: the reference
|
||||
// still first, a render-derived one only where the show supplied none.
|
||||
// Everything the client reads is in their own naming: the alias replaces
|
||||
// our shot code, and the same swap runs over task titles, which carry the
|
||||
// code by convention ("UNG_106_010_020_cmp_TT_v004"). The internal code is
|
||||
// not sent at all — there is nothing here for it to be useful for, and
|
||||
// leaking it invites the client to quote it back at us.
|
||||
shots: shots.map(({ generatedThumbnailUrl, clientCode, ...shot }) => ({
|
||||
...shot,
|
||||
shotCode: clientShotCode({ ...shot, clientCode }),
|
||||
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
|
||||
tasks: shot.tasks.map((task) => ({
|
||||
...task,
|
||||
title: clientTaskTitle(task.title, { ...shot, clientCode }),
|
||||
})),
|
||||
})),
|
||||
assetTasks,
|
||||
sessionLabel: session.label,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { validateReviewToken } from "@/lib/review-auth";
|
||||
import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth";
|
||||
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code";
|
||||
|
||||
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
|
||||
export async function GET(
|
||||
@@ -40,7 +41,7 @@ export async function GET(
|
||||
task: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
shot: { select: { id: true, shotCode: true, highResKey: true, highResFilename: true } },
|
||||
shot: { select: { id: true, shotCode: true, clientCode: true, highResKey: true, highResFilename: true } },
|
||||
asset: { select: { assetCode: true, name: true } },
|
||||
},
|
||||
},
|
||||
@@ -64,7 +65,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const comments = await db.comment.findMany({
|
||||
const commentRows = await db.comment.findMany({
|
||||
where: { versionId },
|
||||
orderBy: { frameNumber: "asc" },
|
||||
include: {
|
||||
@@ -78,9 +79,22 @@ export async function GET(
|
||||
},
|
||||
});
|
||||
|
||||
// Which notes this reviewer may edit or delete. Read-only: opening a review
|
||||
// link should not create the guest User, so a session that has never posted
|
||||
// simply owns nothing. The edit/delete route re-checks this — the flag drives
|
||||
// the UI, it does not grant anything.
|
||||
const reviewUser = await findReviewUser(session, token);
|
||||
const comments = commentRows.map((c) => ({
|
||||
...c,
|
||||
canEdit: ownsComment(c, reviewUser?.id),
|
||||
}));
|
||||
|
||||
// Resolve the shot that has the high-res file (comes from version.shot OR version.task.shot)
|
||||
const highResShot = version.shot ?? (version.task as any)?.shot ?? null;
|
||||
|
||||
// The shot the title and breadcrumb are drawn from — either relation.
|
||||
const namingShot = version.shot ?? ((version.task as any)?.shot ?? null);
|
||||
|
||||
const serializedVersion = {
|
||||
...version,
|
||||
fileSize: version.fileSize?.toString() ?? null,
|
||||
@@ -92,17 +106,24 @@ export async function GET(
|
||||
shot: version.shot
|
||||
? {
|
||||
...version.shot,
|
||||
shotCode: clientShotCode(version.shot),
|
||||
clientCode: undefined,
|
||||
hasHighRes: !!version.shot.highResKey,
|
||||
highResFilename: version.shot.highResFilename ?? null,
|
||||
highResKey: undefined,
|
||||
}
|
||||
: null,
|
||||
// Task titles carry the shot code by convention, so the player header gets
|
||||
// the same swap as the shot list (lib/shot-code.ts).
|
||||
task: version.task
|
||||
? {
|
||||
...version.task,
|
||||
title: namingShot ? clientTaskTitle(version.task.title, namingShot) : version.task.title,
|
||||
shot: (version.task as any)?.shot
|
||||
? {
|
||||
...((version.task as any).shot),
|
||||
shotCode: clientShotCode((version.task as any).shot),
|
||||
clientCode: undefined,
|
||||
highResKey: undefined, // strip storage key
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { cancelDeliveryPackage } from "@/lib/render-pipeline/deliveries";
|
||||
|
||||
// ── POST /api/deliveries/{id}/cancel ─────────────────────────────────────────
|
||||
//
|
||||
// Packages are never deleted (§11.5); a mistaken one is cancelled.
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ packageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const { packageId } = await params;
|
||||
const result = await cancelDeliveryPackage(packageId, user.id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { markDelivered } from "@/lib/render-pipeline/deliveries";
|
||||
|
||||
// ── POST /api/deliveries/{id}/delivered (E19) ────────────────────────────────
|
||||
//
|
||||
// The folder has been uploaded to the client. Moves every packaged export to
|
||||
// DELIVERED — the end of the pipeline.
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ packageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const { packageId } = await params;
|
||||
const result = await markDelivered(packageId, user.id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||
import { getReadyForDelivery } from "@/lib/render-pipeline/deliveries";
|
||||
|
||||
// ── GET /api/deliveries/ready?projectId=&episode= (E18) ──────────────────────
|
||||
//
|
||||
// READY_FOR_DELIVERY exports grouped by episode — "Episode 101 — Ready: 18 shots".
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requirePipelineUser();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const projectId = searchParams.get("projectId");
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
|
||||
}
|
||||
const result = await getReadyForDelivery(projectId, searchParams.get("episode"));
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { DeliveryKind, DeliveryStatus } from "@prisma/client";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineUser, requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import {
|
||||
createDeliveryPackage,
|
||||
createDeliverySchema,
|
||||
listDeliveryPackages,
|
||||
} from "@/lib/render-pipeline/deliveries";
|
||||
|
||||
// ── GET /api/deliveries (E17) · POST /api/deliveries (E16) ───────────────────
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requirePipelineUser();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const status = searchParams.get("status");
|
||||
const kind = searchParams.get("kind");
|
||||
const result = await listDeliveryPackages({
|
||||
projectId: searchParams.get("projectId") ?? undefined,
|
||||
episode: searchParams.get("episode") ?? undefined,
|
||||
status: status ? (status as DeliveryStatus) : undefined,
|
||||
kind: kind ? (kind as DeliveryKind) : undefined,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Building a package writes to the delivery volume and locks versions —
|
||||
// producer/supervisor territory, not any signed-in user.
|
||||
const user = await requirePipelineAdmin();
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = createDeliverySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid delivery request", details: parsed.error.issues }, { status: 422 });
|
||||
}
|
||||
|
||||
const result = await createDeliveryPackage(parsed.data, user.id);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { qcSubmitSchema, submitQCReview } from "@/lib/render-pipeline/qc";
|
||||
|
||||
// ── POST /api/ext/exports/{exportId}/qc (E4) ─────────────────────────────────
|
||||
//
|
||||
// QC Pass / QC Fail from the AE panel. PASS → READY_FOR_DELIVERY and nothing
|
||||
// else; FAIL → QC_FAILED and the comp task back to CHANGES (§10.2).
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ exportId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { exportId } = await params;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = qcSubmitSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid QC submission", details: parsed.error.issues }, { status: 422 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await submitQCReview(exportId, parsed.data, { source: "AE_PANEL" });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { regeneratePreview } from "@/lib/render-pipeline/preview";
|
||||
|
||||
// ── POST /api/ext/exports/{exportId}/regenerate-preview (E15b) ───────────────
|
||||
//
|
||||
// Rebuild the preview MOV/MP4 for an export that already delivered one, after a
|
||||
// slate or burn-in mistake was caught. Reuses the validated EXRs and keeps the
|
||||
// version number; slate text edited on the web app's Slate tab is picked up.
|
||||
//
|
||||
// Body (all optional):
|
||||
// vfxScope per-export override; omit to re-read the shot's slate field
|
||||
// submissionNote same
|
||||
// note audit note for the ExportEvent
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ exportId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { exportId } = await params;
|
||||
|
||||
let body: {
|
||||
vfxScope?: string | null;
|
||||
submissionNote?: string | null;
|
||||
note?: string;
|
||||
} = {};
|
||||
try {
|
||||
const parsed = await req.json();
|
||||
if (parsed && typeof parsed === "object") body = parsed;
|
||||
} catch {
|
||||
// empty body is fine — everything falls back to the shot's slate fields
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await regeneratePreview(exportId, {
|
||||
...(body.vfxScope !== undefined ? { vfxScope: body.vfxScope } : {}),
|
||||
...(body.submissionNote !== undefined ? { submissionNote: body.submissionNote } : {}),
|
||||
note: typeof body.note === "string" ? body.note : "Regenerate preview from AE panel",
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body: errBody, status } = toErrorResponse(err);
|
||||
return NextResponse.json(errBody, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { retryExport } from "@/lib/render-pipeline/exports";
|
||||
|
||||
// ── POST /api/ext/exports/{exportId}/retry (E14) ─────────────────────────────
|
||||
//
|
||||
// Retry a *_FAILED export from the AE panel: new RenderJob attempt, back to QUEUED.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ exportId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { exportId } = await params;
|
||||
let note: string | undefined;
|
||||
try {
|
||||
const body = await req.json();
|
||||
if (typeof body?.note === "string") note = body.note;
|
||||
} catch {
|
||||
// empty body is fine
|
||||
}
|
||||
try {
|
||||
const result = await retryExport(exportId, { type: "USER", note: note ?? "Retry from AE panel" });
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { getExportDetail } from "@/lib/render-pipeline/exports";
|
||||
|
||||
// ── GET /api/ext/exports/{exportId} (E3) ─────────────────────────────────────
|
||||
//
|
||||
// Export detail incl. render attempts and audit events (validations/QC join in
|
||||
// later phases).
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ exportId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { exportId } = await params;
|
||||
try {
|
||||
const result = await getExportDetail(exportId);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { getLatestExport } from "@/lib/render-pipeline/exports";
|
||||
|
||||
// ── GET /api/ext/exports/latest?shotCode=&projectCode= (E2) ──────────────────
|
||||
//
|
||||
// Latest export + status for the AE panel status header. Returns
|
||||
// { "export": null } when the shot has never been exported.
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { searchParams } = new URL(req.url);
|
||||
const shotCode = searchParams.get("shotCode")?.trim();
|
||||
const projectCode = searchParams.get("projectCode")?.trim();
|
||||
if (!shotCode) {
|
||||
return NextResponse.json({ error: "shotCode query param is required" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await getLatestExport(shotCode, projectCode);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { createExport } from "@/lib/render-pipeline/exports";
|
||||
|
||||
// ── POST /api/ext/exports (E1 — Queue Export) ────────────────────────────────
|
||||
//
|
||||
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force?, skipPreview? }
|
||||
// The server decides the new version number, updates Shot.shotVersion/exrOutput,
|
||||
// supersedes older non-terminal exports, and creates Export(QUEUED) + RenderJob.
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
let body: {
|
||||
manifest?: unknown;
|
||||
submittedByEmail?: string;
|
||||
priority?: number;
|
||||
force?: boolean;
|
||||
vfxScope?: string | null;
|
||||
submissionNote?: string | null;
|
||||
skipPreview?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (!body.manifest) {
|
||||
return NextResponse.json({ error: "manifest is required" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await createExport({
|
||||
manifest: body.manifest,
|
||||
submittedByEmail: body.submittedByEmail ?? null,
|
||||
priority: typeof body.priority === "number" ? body.priority : undefined,
|
||||
force: body.force === true,
|
||||
// undefined inherits the shot's previous export values
|
||||
vfxScope: body.vfxScope,
|
||||
submissionNote: body.submissionNote,
|
||||
skipPreview: body.skipPreview === true,
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (err) {
|
||||
const { body: errBody, status } = toErrorResponse(err);
|
||||
return NextResponse.json(errBody, { status });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,6 +19,7 @@ function isAuthorized(req: NextRequest): boolean {
|
||||
//
|
||||
// Query params (all optional):
|
||||
// episode e.g. 108
|
||||
// scene e.g. 022
|
||||
// sequence e.g. 004
|
||||
// status e.g. IN_PROGRESS
|
||||
// page (default 1)
|
||||
@@ -39,6 +41,7 @@ export async function GET(
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
const episode = searchParams.get("episode")?.trim() || undefined;
|
||||
const scene = searchParams.get("scene")?.trim() || undefined;
|
||||
const sequence = searchParams.get("sequence")?.trim() || undefined;
|
||||
const status = searchParams.get("status")?.trim() || undefined;
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
||||
@@ -59,6 +62,7 @@ export async function GET(
|
||||
where: {
|
||||
projectId: project.id,
|
||||
...(episode ? { episode } : {}),
|
||||
...(scene ? { scene } : {}),
|
||||
...(sequence ? { sequence } : {}),
|
||||
...(status ? { status: status as never } : {}),
|
||||
},
|
||||
@@ -88,6 +92,7 @@ export async function GET(
|
||||
seqTimecodeStart: true,
|
||||
seqTimecodeEnd: true,
|
||||
thumbnailUrl: true,
|
||||
generatedThumbnailUrl: true,
|
||||
artist: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
@@ -98,6 +103,7 @@ export async function GET(
|
||||
where: {
|
||||
projectId: project.id,
|
||||
...(episode ? { episode } : {}),
|
||||
...(scene ? { scene } : {}),
|
||||
...(sequence ? { sequence } : {}),
|
||||
...(status ? { status: status as never } : {}),
|
||||
},
|
||||
@@ -107,6 +113,9 @@ export async function GET(
|
||||
return NextResponse.json({
|
||||
project,
|
||||
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
|
||||
shots,
|
||||
shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({
|
||||
...shot,
|
||||
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { resolveProjectTemplates } from "@/lib/render-pipeline/project-templates";
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,11 +51,22 @@ export async function GET(req: NextRequest) {
|
||||
status: true,
|
||||
startDate: true,
|
||||
dueDate: true,
|
||||
pipelineConfig: true,
|
||||
_count: {
|
||||
select: { shots: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ projects, total: projects.length });
|
||||
// Templates are resolved here rather than in the panel so that the panel and
|
||||
// the headless preview build cannot disagree about which template, or which
|
||||
// show LUT, a shot was built with.
|
||||
const withTemplates = await Promise.all(
|
||||
projects.map(async ({ pipelineConfig, ...project }) => ({
|
||||
...project,
|
||||
templates: await resolveProjectTemplates({ ...project, pipelineConfig }),
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json({ projects: withTemplates, total: withTemplates.length });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { getQCQueue } from "@/lib/render-pipeline/qc";
|
||||
|
||||
// ── GET /api/ext/qc/queue?projectCode=&episode= (E5) ─────────────────────────
|
||||
//
|
||||
// Exports sitting in READY_FOR_QC, oldest first, with everything the panel
|
||||
// needs to import the real EXR sequence for full-res QC.
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
try {
|
||||
const result = await getQCQueue({
|
||||
projectCode: searchParams.get("projectCode"),
|
||||
episode: searchParams.get("episode"),
|
||||
limit: searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { createBlenderJob, createBlenderJobSchema } from "@/lib/render-pipeline/blender";
|
||||
|
||||
// ── POST /api/ext/render/blender ─────────────────────────────────────────────
|
||||
//
|
||||
// Queue a standalone Blender render from the Blender add-on. No Export, no shot
|
||||
// version, no validation or QC — the .blend is already set up to render, and
|
||||
// this puts it on the farm instead of on the artist's machine.
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = createBlenderJobSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid Blender job", details: parsed.error.issues }, { status: 422 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createBlenderJob(parsed.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
|
||||
import { db } from "@/lib/db";
|
||||
import { generateHetznerPresignedUploadUrl, sanitizeFileName } from "@/lib/storage";
|
||||
|
||||
// ── POST /api/ext/render/jobs/{jobId}/artifact-presign (E20) ─────────────────
|
||||
//
|
||||
// Presigned upload URL for worker artifacts. Kinds map to the existing object
|
||||
// storage folder layout: preview → videos/, thumbnail → image/,
|
||||
// metadata/log → renders/.
|
||||
|
||||
const KIND_FOLDERS: Record<string, string> = {
|
||||
preview: "videos",
|
||||
thumbnail: "image",
|
||||
metadata: "renders",
|
||||
log: "renders",
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { jobId } = await params;
|
||||
let body: { machineId?: string; kind?: string; fileName?: string; contentType?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
if (!body.machineId) throw new PipelineError(400, "machineId is required");
|
||||
if (!body.fileName) throw new PipelineError(400, "fileName is required");
|
||||
if (!body.contentType) throw new PipelineError(400, "contentType is required");
|
||||
const folder = KIND_FOLDERS[body.kind ?? ""];
|
||||
if (!folder) {
|
||||
throw new PipelineError(422, `kind must be one of: ${Object.keys(KIND_FOLDERS).join(", ")}`);
|
||||
}
|
||||
|
||||
const job = await db.renderJob.findUnique({ where: { id: jobId }, select: { machineId: true } });
|
||||
if (!job) throw new PipelineError(404, "Render job not found");
|
||||
if (job.machineId !== body.machineId) {
|
||||
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
|
||||
}
|
||||
|
||||
const key = `${folder}/${randomUUID()}-${sanitizeFileName(body.fileName)}`;
|
||||
const presignedUrl = await generateHetznerPresignedUploadUrl(key, body.contentType);
|
||||
return NextResponse.json({ presignedUrl, key, url: `/api/files/${key}` });
|
||||
} catch (err) {
|
||||
const { body: errBody, status } = toErrorResponse(err);
|
||||
return NextResponse.json(errBody, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { completeRender } from "@/lib/render-pipeline/jobs";
|
||||
|
||||
// ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ──────────────────
|
||||
//
|
||||
// aerender finished with exit 0 → Export goes to VALIDATING and the worker
|
||||
// keeps the job to run the checks (E12). With validation disabled the preview
|
||||
// stage is queued directly. `validationRequired` in the response tells the
|
||||
// worker which of the two happened.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { jobId } = await params;
|
||||
let body: {
|
||||
machineId?: string;
|
||||
renderSeconds?: number;
|
||||
logFileKey?: string;
|
||||
logTail?: string;
|
||||
exrFileCount?: number;
|
||||
exrTotalBytes?: number;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (!body.machineId) {
|
||||
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await completeRender(jobId, body.machineId, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body: errBody, status } = toErrorResponse(err);
|
||||
return NextResponse.json(errBody, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { deliveryReportSchema, reportDeliveryBuild } from "@/lib/render-pipeline/deliveries";
|
||||
|
||||
// ── POST /api/ext/render/jobs/{jobId}/delivery ───────────────────────────────
|
||||
//
|
||||
// The worker finished (or gave up on) a DELIVERY_BUILD job: per-item results,
|
||||
// the manifest it wrote, and whether the package as a whole is good.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { jobId } = await params;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = deliveryReportSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Delivery report is malformed", details: parsed.error.issues }, { status: 422 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await reportDeliveryBuild(jobId, parsed.data);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { reportFail } from "@/lib/render-pipeline/jobs";
|
||||
|
||||
// ── POST /api/ext/render/jobs/{jobId}/fail (E10) ─────────────────────────────
|
||||
//
|
||||
// Failure report. If retryable and attempts remain the server auto-creates the
|
||||
// next attempt and returns autoRequeued: true.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { jobId } = await params;
|
||||
let body: {
|
||||
machineId?: string;
|
||||
stage?: string;
|
||||
exitCode?: number;
|
||||
errorMessage?: string;
|
||||
logTail?: string;
|
||||
logFileKey?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (!body.machineId) {
|
||||
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await reportFail(jobId, body.machineId, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body: errBody, status } = toErrorResponse(err);
|
||||
return NextResponse.json(errBody, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { finalizePreview, type FinalizeInput } from "@/lib/render-pipeline/preview";
|
||||
|
||||
// ── POST /api/ext/render/jobs/{jobId}/finalize (E13) ─────────────────────────
|
||||
//
|
||||
// Preview artifacts uploaded → register the review MP4 as an internal-only
|
||||
// Version and move the Export to READY_FOR_QC. Never shares to the client
|
||||
// portal and never changes task/shot status (§10.0).
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { jobId } = await params;
|
||||
|
||||
let body: FinalizeInput & { machineId?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (!body.machineId) {
|
||||
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await finalizePreview(jobId, body.machineId, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body: errBody, status } = toErrorResponse(err);
|
||||
return NextResponse.json(errBody, { status });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user