Compare commits
49 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 |
+2
-2
@@ -32,5 +32,5 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# Local tooling
|
# RenderWorker build output is covered by RenderWorker/.gitignore (bin/, obj/,
|
||||||
RenderWorker/
|
# 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 |
|
| `POST` | `/api/ext/shots` | Create a new shot |
|
||||||
| `PATCH` | `/api/ext/shots/{shotId}` | Update shot fields (e.g. increment version) |
|
| `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
|
## 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}` |
|
| 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}` |
|
| 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" }` |
|
| 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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
+150
-6
@@ -207,7 +207,7 @@ stateDiagram-v2
|
|||||||
| T9 | VALIDATION_FAILED | QUEUED | Retry (full re-render) | User | new attempt; old validation rows kept for history |
|
| T9 | VALIDATION_FAILED | QUEUED | Retry (full re-render) | User | new attempt; old validation rows kept for history |
|
||||||
| T10 | GENERATING_PREVIEW | PREVIEW_FAILED | ffmpeg/upload failure | Worker | error stored |
|
| T10 | GENERATING_PREVIEW | PREVIEW_FAILED | ffmpeg/upload failure | Worker | error stored |
|
||||||
| T11 | PREVIEW_FAILED | GENERATING_PREVIEW | Retry preview | User | validation is NOT re-run |
|
| T11 | PREVIEW_FAILED | GENERATING_PREVIEW | Retry preview | User | validation is NOT re-run |
|
||||||
| T12 | GENERATING_PREVIEW | READY_FOR_QC | `finalize` report | Worker | `Version` row created (isLatest, **never client-shared**), thumbnail set, metadata JSON key stored; **no task/shot status change** — QC typically runs on already client-approved shots and must not disturb review state (Section 10) |
|
| T12 | GENERATING_PREVIEW | READY_FOR_QC | `finalize` report | Worker | `Version` row created (isLatest, **never client-shared**), thumbnail set, metadata JSON key stored; `Shot.generatedThumbnailUrl` set when the project opts in (§9.1a) — never `Shot.thumbnailUrl`; **no task/shot status change** — QC typically runs on already client-approved shots and must not disturb review state (Section 10) |
|
||||||
| T13 | READY_FOR_QC | QC_FAILED | `POST .../qc {FAIL}` | Artist (AE) or user (web) | `QCReview` row; task → CHANGES (drives existing Shot REVISIONS derivation) |
|
| T13 | READY_FOR_QC | QC_FAILED | `POST .../qc {FAIL}` | Artist (AE) or user (web) | `QCReview` row; task → CHANGES (drives existing Shot REVISIONS derivation) |
|
||||||
| T14 | READY_FOR_QC | READY_FOR_DELIVERY | `POST .../qc {PASS}` | Artist / user | `QCReview` row |
|
| T14 | READY_FOR_QC | READY_FOR_DELIVERY | `POST .../qc {PASS}` | Artist / user | `QCReview` row |
|
||||||
| T15 | READY_FOR_DELIVERY | PACKAGED | Delivery build completes | Worker | `DeliveryItem` links Export to package |
|
| T15 | READY_FOR_DELIVERY | PACKAGED | Delivery build completes | Worker | `DeliveryItem` links Export to package |
|
||||||
@@ -268,6 +268,9 @@ model Export {
|
|||||||
shot Shot @relation(fields: [shotId], references: [id])
|
shot Shot @relation(fields: [shotId], references: [id])
|
||||||
projectId String
|
projectId String
|
||||||
taskId String? // comp task the preview Version attaches to
|
taskId String? // comp task the preview Version attaches to
|
||||||
|
// (E1 creates one named after the preview file
|
||||||
|
// when the shot has none, so the preview lands
|
||||||
|
// somewhere the app shows it)
|
||||||
versionId String? // preview Version created at READY_FOR_QC
|
versionId String? // preview Version created at READY_FOR_QC
|
||||||
versionNumber Int // 4
|
versionNumber Int // 4
|
||||||
versionString String // "v004"
|
versionString String // "v004"
|
||||||
@@ -448,6 +451,7 @@ model DeliveryPackage {
|
|||||||
projectId String
|
projectId String
|
||||||
episode String // "101"
|
episode String // "101"
|
||||||
name String // "260805_Delivery" (per 18.1-Q6 convention)
|
name String // "260805_Delivery" (per 18.1-Q6 convention)
|
||||||
|
kind DeliveryKind @default(EXR_DELIVERY) // EXR_DELIVERY | MOV_REVIEW (§11.6)
|
||||||
packageNumber Int // sequential per project → "Package #15"
|
packageNumber Int // sequential per project → "Package #15"
|
||||||
status DeliveryStatus @default(DRAFT)
|
status DeliveryStatus @default(DRAFT)
|
||||||
rootPath String? // final path on delivery volume
|
rootPath String? // final path on delivery volume
|
||||||
@@ -464,6 +468,7 @@ model DeliveryPackage {
|
|||||||
|
|
||||||
@@unique([projectId, packageNumber])
|
@@unique([projectId, packageNumber])
|
||||||
@@index([projectId, episode])
|
@@index([projectId, episode])
|
||||||
|
@@index([projectId, kind])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DeliveryItem {
|
model DeliveryItem {
|
||||||
@@ -488,6 +493,8 @@ model DeliveryItem {
|
|||||||
|
|
||||||
**Version locking:** the package references `Export` rows, and items denormalise `shotCode`/`versionString` at creation. Later re-renders of a shot never mutate a package — delivery history is immutable by construction.
|
**Version locking:** the package references `Export` rows, and items denormalise `shotCode`/`versionString` at creation. Later re-renders of a shot never mutate a package — delivery history is immutable by construction.
|
||||||
|
|
||||||
|
**`kind`** distinguishes a shippable EXR delivery from a MOV review folder (§11.6). Both use these two models and the same `DELIVERY_BUILD` job; only a review leaves `exrPath` null, and only a delivery advances its exports to PACKAGED/DELIVERED.
|
||||||
|
|
||||||
### 5.8 Changes to existing models (additive only)
|
### 5.8 Changes to existing models (additive only)
|
||||||
|
|
||||||
| Model | Addition | Purpose |
|
| Model | Addition | Purpose |
|
||||||
@@ -579,6 +586,19 @@ Uploaded by the AE panel on Queue Export; snapshotted verbatim into `RenderJob.m
|
|||||||
"alpha": false,
|
"alpha": false,
|
||||||
"timecodeStart": "00:59:49:12"
|
"timecodeStart": "00:59:49:12"
|
||||||
},
|
},
|
||||||
|
"source": {
|
||||||
|
"startSource": "footage metadata",
|
||||||
|
"handleFrames": 8,
|
||||||
|
"timingPlate": {
|
||||||
|
"path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_BG01_TT_v001/UNG_106_010_020_BG01_TT_v001.01001.exr",
|
||||||
|
"frameStart": 1001,
|
||||||
|
"frameEnd": 1120
|
||||||
|
},
|
||||||
|
"plates": [
|
||||||
|
{ "element": "BG01_TT_v001", "path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_BG01_TT_v001/UNG_106_010_020_BG01_TT_v001.01001.exr", "frameStart": 1001, "frameEnd": 1120 },
|
||||||
|
{ "element": "FG01_TT_v001", "path": "V:/_FOOTAGE/UNG/UNG_106/UNG_106_010_020_FG01_TT_v001/UNG_106_010_020_FG01_TT_v001.01001.exr", "frameStart": 1001, "frameEnd": 1120 }
|
||||||
|
]
|
||||||
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"template": "default",
|
"template": "default",
|
||||||
"burnins": true
|
"burnins": true
|
||||||
@@ -588,6 +608,15 @@ Uploaded by the AE panel on Queue Export; snapshotted verbatim into `RenderJob.m
|
|||||||
|
|
||||||
The server validates the manifest with Zod, cross-checks `shotId`/`shotCode`/frame range against the DB, and **the server (not the panel) decides the new version number** — race-free single source of truth.
|
The server validates the manifest with Zod, cross-checks `shotId`/`shotCode`/frame range against the DB, and **the server (not the panel) decides the new version number** — race-free single source of truth.
|
||||||
|
|
||||||
|
**`source` (required from manifestVersion 2)** — the plate provenance the timecode check depends on (§8.1a). The panel resolves these from the *comp's own footage layers*, not by re-deriving paths, so the manifest records what was actually comped:
|
||||||
|
|
||||||
|
- `timingPlate` — the delivered plate the render's timecode is checked against. A shot's elements routinely carry unrelated camera timecodes, so "the plate" must be named explicitly rather than guessed by the worker. **Only footage under the Footage Root qualifies**: a comp usually also holds pre-renders and CG passes of identical length, and anchoring on one of those is circular — it was rendered from this comp, so it can only ever agree with it, including when both are wrong. Among delivered plates, longest duration wins.
|
||||||
|
- `frameStart`/`frameEnd` on a plate are its **own file numbering**, and are omitted when the filename carries no frame number. They are never filled from the comp's frame range: the render's first frame is in output numbering, so a comp-frame anchor skews the comparison by the gap between the two systems.
|
||||||
|
- `startSource` — verbatim from the panel's existing start-frame resolution: `"footage metadata"` (probed from the plate, trustworthy) or `"API timecode …"` (fallback, suspect). This is known at queue time, so an export built from the API fallback can be flagged **before** it burns render hours.
|
||||||
|
- `plates[]` — every element imported into the comp, for the record and for cross-element diagnostics.
|
||||||
|
|
||||||
|
Manifests without `source` (manifestVersion 1) are still accepted; the timecode check degrades per §8.1a.
|
||||||
|
|
||||||
### 6.3 Endpoint summary
|
### 6.3 Endpoint summary
|
||||||
|
|
||||||
| # | Method | Path | Auth | Caller | Purpose |
|
| # | Method | Path | Auth | Caller | Purpose |
|
||||||
@@ -1009,15 +1038,62 @@ Runs on the worker immediately after a successful render (Export = VALIDATING).
|
|||||||
| `exr_type` | header: scanline vs tiled, compression | ≠ `expected.exrCompression` (WARN if compression differs but readable; FAIL if not valid EXR) |
|
| `exr_type` | header: scanline vs tiled, compression | ≠ `expected.exrCompression` (WARN if compression differs but readable; FAIL if not valid EXR) |
|
||||||
| `alpha_channel` | channel list contains A | presence ≠ `expected.alpha` |
|
| `alpha_channel` | channel list contains A | presence ≠ `expected.alpha` |
|
||||||
| `colourspace` | EXR metadata (`chromaticities` / ACES container flag / colorspace attr written by AE OCIO) | ≠ `expected.colorspace` (working space is linear ACES2065-1 per 18.1-Q4); `colorPipeline` is required per project (18.1-Q12), so SKIPPED only on legacy projects predating pipeline enablement |
|
| `colourspace` | EXR metadata (`chromaticities` / ACES container flag / colorspace attr written by AE OCIO) | ≠ `expected.colorspace` (working space is linear ACES2065-1 per 18.1-Q4); `colorPipeline` is required per project (18.1-Q12), so SKIPPED only on legacy projects predating pipeline enablement |
|
||||||
| `timecode_consistency` | EXR `timeCode` attr across sampled frames | non-monotonic, or first frame ≠ `expected.timecodeStart` when set |
|
| `timecode_plate_match` | **primary check — see §8.1a** | render `timeCode` ≠ the timing plate's `timeCode` at the corresponding frame |
|
||||||
|
| `timecode_consistency` | EXR `timeCode` attr across sampled frames | non-monotonic (gap, repeat, or frozen value) |
|
||||||
|
| `timecode_fps` | EXR `framesPerSecond` on the render vs manifest `fps` | any disagreement |
|
||||||
|
| `plate_fps` | EXR `framesPerSecond` on the render vs **the timing plate's** | WARN on any difference — never FAIL: a whole-number multiple is a legitimate over-crank (50 fps plate on a 25 fps show), and anything else is reported as a probable rate mistake |
|
||||||
| `zero_byte / corrupt` | every file size > 0; oiiotool header-read on sampled frames (first, last, every Nth) | any zero-byte or unreadable file |
|
| `zero_byte / corrupt` | every file size > 0; oiiotool header-read on sampled frames (first, last, every Nth) | any zero-byte or unreadable file |
|
||||||
|
|
||||||
Full-sequence checks (count, gaps, names, sizes) run on every file; header checks sample first/middle/last + every 25th frame (fast, catches systemic issues; per-frame full decode is not worth minutes per shot — tunable via `SystemConfig: validation.sampleEvery`).
|
Full-sequence checks (count, gaps, names, sizes) run on every file; header checks sample first/middle/last + every 25th frame (fast, catches systemic issues; per-frame full decode is not worth minutes per shot — tunable via `SystemConfig: validation.sampleEvery`).
|
||||||
|
|
||||||
|
**Two coordinate systems, never conflated.** A render carries frame numbers in two unrelated systems, and every check states which one it uses:
|
||||||
|
|
||||||
|
| | Meaning | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| `frameStart`/`frameEnd` | The **comp's timeline**, handed to aerender as `-s`/`-e`. A comp anchored to its plate's timecode starts at the absolute frame that timecode represents. | `1247792`–`1247896` |
|
||||||
|
| `outputFrameStart` | The number the **first output file** carries, set by the output module template. House convention: every sequence starts at 1001. | `1001` |
|
||||||
|
|
||||||
|
`file_count`, `missing_frames` and `duplicate_frames` are measured on the *output* numbering; `first_frame`/`last_frame` compare against `outputFrameStart` and are SKIPPED when a manifest predates the field, since the count and contiguity checks still hold. Comparing file numbers against comp frames fails every correctly-rendered shot by ~1.2 million frames, and the timecode reference must be anchored on the render's own first frame for the same reason.
|
||||||
|
|
||||||
|
### 8.1a Timecode: the primary check (studio decision)
|
||||||
|
|
||||||
|
Timecode is the check this engine exists for. It is validated **against the source plate**, never against the API, because the API and the plate do not agree by design.
|
||||||
|
|
||||||
|
**Measured on the live show (2026-08-13, 85 rendered sequences + 106 delivered sequences on `V:/`):**
|
||||||
|
|
||||||
|
- AE-written EXRs *do* carry `timeCode` (plus a `timeCodeString` mirror) and `framesPerSecond` — the §17.3 "metadata may be absent" risk does not apply to the studio's current EXR output module. A render whose TC is absent means the output module changed, and is a FAIL.
|
||||||
|
- `Shot.timecodeStart` from the API is the **editorial in-point**; the plate on disk starts `handleFrames` earlier. Measured on three shots, the API value was consistently the plate's TC **+8 frames** — the exact handle count. Validating a render against the API would therefore **pass the defective exports and fail the correct ones**: on `UNG_102_019_010` the correctly-built render (`18:58:57:12`, matching its BG01 plate) differs from the API value (`18:58:57:20`), while on `UNG_102_040_010` the *defective* render matches the API exactly and is 8 frames off its plate.
|
||||||
|
- 95 of 106 delivered sequences match their plate's timecode exactly. The 11 that do not fall into three classes this check catches: **+8 handle offset** (comp anchored to the editorial in-point instead of the plate head), **frame-derived TC** (`00:00:41:17` = frame 1001 at 24 fps, i.e. a comp with no source timecode at all — also seen as `00:00:00:00`), and **one timecode shared across four different shots** in a single delivery package.
|
||||||
|
|
||||||
|
**Reference precedence** — the check records which source it used and never silently substitutes a weaker one:
|
||||||
|
|
||||||
|
| Rank | Source | Verdict semantics |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `source.timingPlate` from the manifest — the element the comp was actually anchored to | PASS / FAIL, authoritative |
|
||||||
|
| 2 | Plate re-derived on the worker from the footage root + shot code (manifestVersion 1, or a timing plate that no longer resolves). If elements disagree, the one matching the render's frame count wins; still ambiguous → WARN, never PASS | PASS / FAIL, `reference: "derived"` |
|
||||||
|
| 3 | API `Shot.timecodeStart` **minus `handleFrames`** | **WARN only, never PASS** — `reference: "api"`, message states the plate could not be read |
|
||||||
|
|
||||||
|
Rank 3 is a diagnostic, not a verification: it depends on the show's handle count being correct for that specific plate, which is exactly the assumption the defects above violate.
|
||||||
|
|
||||||
|
**Comparison, given the plate reference:**
|
||||||
|
|
||||||
|
```
|
||||||
|
expectedTc = plateTc + (render.frameStart - plate.frameStart) // frames, at manifest fps
|
||||||
|
```
|
||||||
|
|
||||||
|
The offset term matters — a shot may render a sub-range of a long plate (observed: 37 rendered frames from a 709-frame plate). Sampled frames must then increment by exactly one frame each; a constant value across the sequence is the frame-derived-TC defect, not a rounding artifact. `timeCodeString` is cross-checked against the packed `timeCode` as a writer sanity check.
|
||||||
|
|
||||||
|
**Queue-time pre-flight.** The panel already knows whether it read the start time from the plate's metadata or fell back to the API (`source.startSource`). When the manifest says the API fallback was used, the Export is flagged at queue time — the render is very likely to fail this check, and there is no point spending machine hours to find out.
|
||||||
|
|
||||||
|
**Delivery-package cross-check (Phase 5).** Within one package, two shots sharing a first-frame timecode is a FAIL on the package: it is cheap, and it is precisely the defect that reached a built delivery package above.
|
||||||
|
|
||||||
|
**Tooling note.** `timeCode`, `timeCodeString` and `framesPerSecond` live in the EXR header, so this check needs one 64 KB read per sampled frame and no pixel decode — the header parse is ~40 lines (attribute list: `name\0 type\0 int32 size data`; `timeCode` is two `uint32`, the first packed SMPTE 12M BCD, which reads back as its own hex digits: `0x18585712` → `18:58:57:12`). It does **not** depend on oiiotool, so the timecode check can ship ahead of the OIIO-based resolution/bit-depth/channel checks.
|
||||||
|
|
||||||
### 8.2 Semantics
|
### 8.2 Semantics
|
||||||
|
|
||||||
- Any FAIL → whole run FAIL → Export = VALIDATION_FAILED, pipeline stops, exact reasons in `ValidationResult` rows (E12 example above shows a missing-frame report).
|
- Any FAIL → whole run FAIL → Export = VALIDATION_FAILED, pipeline stops, exact reasons in `ValidationResult` rows (E12 example above shows a missing-frame report).
|
||||||
- WARN never stops the pipeline but is shown in QC and delivery UIs.
|
- WARN never stops the pipeline but is shown in QC and delivery UIs.
|
||||||
|
- **Manual override.** A check can fail on a render that is genuinely fine — an unreachable plate, an expectation that does not apply to this shot — so a VALIDATION_FAILED export is not a dead end. An admin/producer/supervisor can override it from the Export detail page with a **required reason**, and the pipeline resumes exactly where a pass would have handed off: preview, then READY_FOR_QC. **An override never skips QC and never delivers directly** — a human still looks at the frames. The failed `ValidationResult` rows are left untouched, so the QC queue keeps listing them as warnings, and the reason plus the checks waved through are recorded on the Export's timeline.
|
||||||
- Checksum: xxHash64 per file, sequence digest = xxHash64 of sorted per-file digests; stored on `Export.checksum` and reused by the delivery builder for copy verification (MD5 available for the client-facing manifest per 18.1-Q13).
|
- Checksum: xxHash64 per file, sequence digest = xxHash64 of sorted per-file digests; stored on `Export.checksum` and reused by the delivery builder for copy verification (MD5 available for the client-facing manifest per 18.1-Q13).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1031,18 +1107,36 @@ Runs on the worker after validation passes (Export = GENERATING_PREVIEW).
|
|||||||
### 9.1 Stages
|
### 9.1 Stages
|
||||||
|
|
||||||
1. **Build + render preview comp (AE, headless).** The worker launches `AfterFX.com -noui -r vfxr_build_preview.jsx`, passing a job-context JSON file (manifest + slate/overlay fields fetched from E3). The script is the connector's existing preview-comp code, packaged with the worker: it imports the *validated* EXR sequence, builds the preview comp from the studio template (head slate populated from API data, overlays/burn-ins throughout), applies the colour chain (linear → log → show LUT from `colorPipeline` config), queues it with the project's MOV output-module template, and renders the **delivery-grade preview MOV** to the SAN beside the EXRs (`Export.deliveryMovPath`). This is the "MOV LT Preview" that ships inside delivery packages (Section 11).
|
1. **Build + render preview comp (AE, headless).** The worker launches `AfterFX.com -noui -r vfxr_build_preview.jsx`, passing a job-context JSON file (manifest + slate/overlay fields fetched from E3). The script is the connector's existing preview-comp code, packaged with the worker: it imports the *validated* EXR sequence, builds the preview comp from the studio template (head slate populated from API data, overlays/burn-ins throughout), applies the colour chain (linear → log → show LUT from `colorPipeline` config), queues it with the project's MOV output-module template, and renders the **delivery-grade preview MOV** to the SAN beside the EXRs (`Export.deliveryMovPath`). This is the "MOV LT Preview" that ships inside delivery packages (Section 11).
|
||||||
2. **Web transcode (ffmpeg).** The delivery MOV is transcoded to a web-playable H.264 (1920-wide, CRF 18) and a thumbnail JPEG (frame at 25% duration, 960-wide), uploaded via E20 presign.
|
2. **Web transcode (ffmpeg).** The delivery MOV is transcoded to a web-playable H.264 (1920-wide, CRF 18) and a thumbnail JPEG (960-wide), uploaded via E20 presign. The thumbnail is taken at **25% of *picture* duration**, seeking past the head slate: the build script reports the SHOT layer's in-point as `pictureStartSeconds`, and the worker seeks to `pictureStart + 25%`. Builds that report no picture start fall back to scanning the whole file. (Until 2026-08-27 this used ffmpeg's `thumbnail` filter, which only considers the first 100 frames — on a slated preview, the slate card. Every thumbnail produced before then is a picture of the slate.)
|
||||||
3. **Metadata JSON.** Worker-composed (manifest + validation summary + render stats + checksums + tool/template versions), uploaded via E20.
|
3. **Metadata JSON.** Worker-composed (manifest + validation summary + render stats + checksums + tool/template versions), uploaded via E20.
|
||||||
|
|
||||||
| Artifact | Produced by | Destination |
|
| Artifact | Produced by | Destination |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Delivery MOV (slate + burn-ins, graded via show LUT, delivery codec from AE output template) | AE headless render | SAN → `Export.deliveryMovPath`; copied/linked into delivery packages |
|
| Delivery MOV (slate + burn-ins, graded via show LUT, delivery codec from AE output template) | AE headless render | SAN → `Export.deliveryMovPath`; copied/linked into delivery packages |
|
||||||
| Web preview MOV (H.264) | ffmpeg transcode of the delivery MOV | object storage `videos/` → `Version.fileUrl` |
|
| Web preview MOV (H.264) | ffmpeg transcode of the delivery MOV | object storage `videos/` → `Version.fileUrl` |
|
||||||
| Thumbnail | ffmpeg | `image/` → `Version.thumbnailUrl` + `Export.thumbnailKey` |
|
| Thumbnail | ffmpeg | `image/` → `Version.thumbnailUrl` + `Export.thumbnailKey`; also `Shot.generatedThumbnailUrl` when the project opts in (§9.3) |
|
||||||
| Metadata JSON | worker | `renders/` → `Export.metadataKey` |
|
| Metadata JSON | worker | `renders/` → `Export.metadataKey` |
|
||||||
|
|
||||||
Because the web preview is a transcode of the AE-generated MOV, review media and delivery media are guaranteed to show identical slates, burn-ins, and colour — one source of truth.
|
Because the web preview is a transcode of the AE-generated MOV, review media and delivery media are guaranteed to show identical slates, burn-ins, and colour — one source of truth.
|
||||||
|
|
||||||
|
### 9.1a Render frames as shot thumbnails
|
||||||
|
|
||||||
|
Shots carry two stills, in two columns, and the distinction is the whole design:
|
||||||
|
|
||||||
|
| Column | Written by | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `Shot.thumbnailUrl` | humans only — single upload, batch upload, ext API | The reference frame that came with the brief. Editorial data. |
|
||||||
|
| `Shot.generatedThumbnailUrl` | the pipeline, and the manual promote action | A frame lifted from a finished export. |
|
||||||
|
|
||||||
|
Display resolution is `thumbnailUrl ?? generatedThumbnailUrl` (`lib/shot-thumbnail.ts`), applied at every shot surface: shot grid, kanban, schedule, shot status, storyboard (screen and PDF), shoot log, slate tab, client portal, and the ext APIs the Nuke/AE panel reads. **The reference always wins**, so the pipeline can only fill a gap and can never overwrite the brief — clearing the generated one always falls back.
|
||||||
|
|
||||||
|
Promotion happens two ways:
|
||||||
|
|
||||||
|
* **Automatically**, for projects with `pipelineConfig.autoShotThumbnail` set (Project Settings → *Use render frames as shot thumbnails*). Off by default and deliberately per-show: productions that ship a still with every shot already have a reference frame and the renders should not compete with it. It exists for shows that arrive with no stills at all, where the alternative is an empty grid. `finalizePreview` writes the column and notes it on the export timeline.
|
||||||
|
* **By hand**, via `promoteExportThumbnail` / `clearGeneratedThumbnail` (Shot Settings → Thumbnail → *From renders*), which work regardless of the project flag — one shot on an otherwise hands-off show. With no explicit export, the newest export of that shot that has a thumbnail is used.
|
||||||
|
|
||||||
|
Note this is the one place a render-derived artifact reaches the client portal. It is a still, not a `Version`, so §10.0 is intact — no technical render re-enters the review flow and no approval state moves — but a client viewing a shot with no supplied still now sees a frame from the render rather than a placeholder.
|
||||||
|
|
||||||
### 9.2 Colour configuration
|
### 9.2 Colour configuration
|
||||||
|
|
||||||
`Project.deliveryConfig.colorPipeline` (per 18.1-Q4/Q12) — **required and unique per project**; enabling the pipeline on a project without it is a validation error, and values must never be carried over from another show:
|
`Project.deliveryConfig.colorPipeline` (per 18.1-Q4/Q12) — **required and unique per project**; enabling the pipeline on a project without it is a validation error, and values must never be carried over from another show:
|
||||||
@@ -1063,6 +1157,26 @@ Cost note: the AE preview pass roughly doubles machine time per export. This mat
|
|||||||
|
|
||||||
Finalize (E13) then creates the `Version` row server-side (Section 6.4) in pipeline mode — visible internally, never client-shared, no task/shot status changes.
|
Finalize (E13) then creates the `Version` row server-side (Section 6.4) in pipeline mode — visible internally, never client-shared, no task/shot status changes.
|
||||||
|
|
||||||
|
**Re-exports inherit approval, and carry no artist-facing text.** A shot is routinely re-exported after client approval — most often for an unscaled/un-punched-in delivery pass — and that must not read as new work awaiting review. The new `Version.approvalStatus` is copied from the task's previous latest version rather than left at its `PENDING_REVIEW` default; an export that starts a task's history has nothing to inherit and gets `PENDING_REVIEW` as before. `Version.notes` is left unset rather than stamped with an internal description: `notes` is the artist's message to reviewers and is rendered verbatim in the client portal and the review player, so pipeline provenance belongs on the Export and its `ExportEvent` timeline — where a human would go looking for it — not in a field a client can read.
|
||||||
|
|
||||||
|
**Known gap:** `shareWithClient` (and the per-task share action) promote whichever `Version` is `isLatest` for a task to `isClientVisible: true`, with no awareness that a pipeline-created version is meant to stay internal permanently (this section, "never client-shared"). Re-sharing a shot after a pipeline re-export will currently expose the internal version if it is the task's latest. Closing this needs a way to identify a pipeline-origin version at the share call sites — `Export.versionId` is presently a one-way scalar with no inverse relation on `Version` — and is not yet implemented.
|
||||||
|
|
||||||
|
### 9.3a Where a preview sits in the queue
|
||||||
|
|
||||||
|
The queue is ordered `priority ASC, createdAt ASC`, and a preview job is only created when its render *finishes* — so at equal priority it sorts behind every render queued alongside it. Ten shots queued together would run as ten renders and then ten previews, with nothing reviewable until the end.
|
||||||
|
|
||||||
|
`SystemConfig: render.previewPriorityBoost` (default 1) subtracts from the render's priority when the preview job is created, so a finished shot completes before the next render starts. Priority is otherwise inherited, which is what keeps an urgent export urgent all the way to READY_FOR_QC. The boost never promotes a job across `render.urgentPriorityThreshold`: a normal preview must not gain permission to run outside the render window that its render never had. Set the boost to `0` for the old phase behaviour.
|
||||||
|
|
||||||
|
Independently of priority, a worker **does not claim `PREVIEW_ONLY` jobs while an interactive After Effects is running** on that machine (§9.1) — a headless launch would hijack the artist's session. During the working day previews therefore queue regardless of rank, which is usually the larger effect on when they run.
|
||||||
|
|
||||||
|
### 9.4 Skipping the preview (`Export.skipPreview`)
|
||||||
|
|
||||||
|
Not every export is framed like its review. A deliverable rendered unscaled or without the punch-in shares a shot and a version history with the review, but a preview rebuilt from *its* frames would show the wrong picture — the earlier export's MOV is the correct one.
|
||||||
|
|
||||||
|
`skipPreview` (set at Queue Export, or per-action when overriding a validation failure) renders the EXRs and goes straight to READY_FOR_QC, **inheriting** `deliveryMovPath`, `previewMovKey`, `thumbnailKey` and `versionId` from the newest earlier export of that shot which has them. Inheritance matters as much as the skip: the delivery builder takes its MOV from `deliveryMovPath`, so an export with none would package EXRs alone. QC is unaffected — the export still needs a human pass before it can be delivered.
|
||||||
|
|
||||||
|
With no earlier export to inherit from, the export still reaches READY_FOR_QC and the timeline records that it has no review media.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. QC Workflow
|
## 10. QC Workflow
|
||||||
@@ -1126,7 +1240,11 @@ Tokens: `{showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir} {shot
|
|||||||
|
|
||||||
### 11.3 Hard links vs copying
|
### 11.3 Hard links vs copying
|
||||||
|
|
||||||
**Recommendation: hard link when `deliveryRoot` and render root share an NTFS volume; transparent fallback to copy otherwise (cross-volume links are impossible).** Hard links make an 18-shot EXR package near-instant and ~zero extra disk; the risk (mutating a linked file changes both) is acceptable because rendered EXR versions are immutable by pipeline design — a version is never re-rendered in place (new version → new directory). Post-link verification: link target identity check; post-copy verification: size + xxHash against validation-time values. `DeliveryItem.status` → VERIFIED per item. Config override `"copyMode": "always-copy"` for productions that mandate physical separation.
|
**Recommendation: hard link when `deliveryRoot` and render root share an NTFS volume; transparent fallback to copy otherwise (cross-volume links are impossible).** Hard links make an 18-shot EXR package near-instant and ~zero extra disk; the risk (mutating a linked file changes both) is acceptable because rendered EXR versions are immutable by pipeline design — a version is never re-rendered in place (new version → new directory).
|
||||||
|
|
||||||
|
**Verified on the studio volume (2026-08-13):** `V:` is `\\TRUENAS\vfx` over SMB, and hard links work on it — `mklink /H` succeeds and a write through one name is visible through the other. Renders (`V:/_EXPORTS/UNG/{ep}/{shot}/{ver}`) and the delivery root (`V:/_EXPORTS/UNG/DELIVERY`) are on the same share, so the fast path applies to real deliveries today.
|
||||||
|
|
||||||
|
**Implementation trap:** `fsutil hardlink list` fails over SMB (`Error 50: The request is not supported`), so the builder **cannot enumerate links to verify them** on a network volume. Verify instead by hashing the delivery-side file and comparing against the validation-time xxHash — that reads the same bytes whether the entry is a link or a copy, so one verification path covers both modes. Post-copy verification is the same check plus size. `DeliveryItem.status` → VERIFIED per item. Config override `"copyMode": "always-copy"` for productions that mandate physical separation.
|
||||||
|
|
||||||
### 11.4 Manifest and delivery report
|
### 11.4 Manifest and delivery report
|
||||||
|
|
||||||
@@ -1137,6 +1255,27 @@ Tokens: `{showId} {episode} {yymmdd} {yyyymmdd} {packageName} {packageDir} {shot
|
|||||||
|
|
||||||
The Delivery Packages page (Section 13) lists every package permanently: `#15 · 101/260805_Delivery · Episode 101 · 18 shots · built 2026-08-05 · delivered 2026-08-05 by Chris`, expandable to items → Export → full render/validation/QC history. Packages are never deleted; a mistaken package is CANCELLED (DRAFT/QUEUED/FAILED only) or superseded by building a new one.
|
The Delivery Packages page (Section 13) lists every package permanently: `#15 · 101/260805_Delivery · Episode 101 · 18 shots · built 2026-08-05 · delivered 2026-08-05 by Chris`, expandable to items → Export → full render/validation/QC history. Packages are never deleted; a mistaken package is CANCELLED (DRAFT/QUEUED/FAILED only) or superseded by building a new one.
|
||||||
|
|
||||||
|
### 11.6 MOV review packages (`DeliveryPackage.kind = MOV_REVIEW`)
|
||||||
|
|
||||||
|
A formal review is shared as a flat, dated folder of the preview MOVs — the same package/job/verification machinery as §11.1–11.5, a different shape on disk:
|
||||||
|
|
||||||
|
```
|
||||||
|
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review/
|
||||||
|
├── EP6_VFX_012_v004.mov ← client naming where the production supplies it
|
||||||
|
├── EP6_VFX_013_v002.mov
|
||||||
|
└── UNG_106_010_040_cmp_TT_v001.mov ← ours where it does not
|
||||||
|
```
|
||||||
|
|
||||||
|
The second review that day is `_02`, the third `_03`; the first carries no suffix. Numbering counts every package built under that base — cancelled and failed ones included — so a number is never reused for a different folder. Config: `reviewNameTemplate` (default `{yyyymmdd}_Producer_review`) and `layout.reviewDir` (default `{deliveryRoot}/PRODUCER_REVIEWS/{packageName}`).
|
||||||
|
|
||||||
|
Three rules separate a review from a delivery:
|
||||||
|
|
||||||
|
- **It never advances an Export.** Reviews happen throughout a shot's life, long before QC passes it, and the same version is routinely shown twice. Nothing is marked PACKAGED or DELIVERED when the folder is built, and `Mark delivered` is refused on a review package.
|
||||||
|
- **Selection is by shot, not by export.** The user picks shots on Shot Status → *Bulk Actions → Build MOV Review Package*; the server resolves each to its **newest export that actually has a `deliveryMovPath`** — not necessarily its newest export, since a shot whose latest version is still rendering should go into the review as the version that can be watched. Shots with no MOV at all are listed in the confirm dialog and left out rather than silently dropped.
|
||||||
|
- **The folder is exactly the MOVs.** No CSV, no `manifest.json` written beside them. The manifest still returns to the server, so the record of what was shared is kept without cluttering the share.
|
||||||
|
|
||||||
|
Verification is proportional as in §11.3, adapted to a single file with no validation-time digest: a hard link is the source and needs nothing; a copy is checked against its source by length always, and by full hash under `"verifyMode": "always-hash"`. A missing MOV **fails the item** here — in an EXR delivery the MOV is a viewing copy beside the real deliverable, but in a review it *is* the deliverable, and a review folder quietly one shot short is worse than one that says so.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. AE Panel Changes
|
## 12. AE Panel Changes
|
||||||
@@ -1153,6 +1292,11 @@ The panel keeps all existing capabilities (episode/shot discovery, overlays, pic
|
|||||||
| Latest Export + QC status | new E2 `exports/latest` — e.g. `v004 — READY_FOR_QC`, `v004 — RENDERING 42% (ETA 1:36 on RENDER-01)`, `v003 — QC FAILED: "grain mismatch"` |
|
| Latest Export + QC status | new E2 `exports/latest` — e.g. `v004 — READY_FOR_QC`, `v004 — RENDERING 42% (ETA 1:36 on RENDER-01)`, `v003 — QC FAILED: "grain mismatch"` |
|
||||||
|
|
||||||
While an export is active the panel polls E2 every 15 s to live-update the header.
|
While an export is active the panel polls E2 every 15 s to live-update the header.
|
||||||
|
The poll stops when the export leaves an active status, and also when the panel is
|
||||||
|
closed or reloaded — `app.scheduleTask` outlives the panel instance that armed it,
|
||||||
|
so the task id is kept in ExtendScript global scope where the next instance can
|
||||||
|
still cancel it. The export is unaffected either way: it runs on the farm, and the
|
||||||
|
header picks its status back up on the next Refresh.
|
||||||
|
|
||||||
### 12.2 Buttons and their API calls
|
### 12.2 Buttons and their API calls
|
||||||
|
|
||||||
@@ -1278,7 +1422,7 @@ Five independently deployable phases. Each phase leaves the previous workflow fu
|
|||||||
| API | E12; transitions T3→T7/T8 activated (Phase 2 interim: complete-render goes straight to a provisional READY_FOR_QC; Phase 3 inserts VALIDATING between) |
|
| API | E12; transitions T3→T7/T8 activated (Phase 2 interim: complete-render goes straight to a provisional READY_FOR_QC; Phase 3 inserts VALIDATING between) |
|
||||||
| UI | Validation table on Export detail; Validation Results browser; failure badge in queue |
|
| UI | Validation table on Export detail; Validation Results browser; failure badge in queue |
|
||||||
| Worker | Validation engine (oiiotool integration, all Section 8.1 checks, checksums); oiiotool bundled with installer |
|
| Worker | Validation engine (oiiotool integration, all Section 8.1 checks, checksums); oiiotool bundled with installer |
|
||||||
| Risks | Colourspace/timecode metadata may be absent in AE-written EXRs → those checks SKIPPED until per-project expectations configured (18.2-Q1); false FAILs block artists → per-check severity override in SystemConfig (`validation.overrides: {"exr_type": "warn"}`) |
|
| Risks | ~~Timecode metadata may be absent in AE-written EXRs~~ — disproved on the live show (§8.1a): AE writes `timeCode`, so absence is now itself a FAIL. Colourspace metadata may still be absent → that check SKIPPED until per-project expectations configured (18.2-Q1); false FAILs block artists → per-check severity override in SystemConfig (`validation.overrides: {"exr_type": "warn"}`). **The timecode check is the phase's priority and ships first** — it needs no oiiotool, and 11 of 106 delivered sequences on the current show would have been caught by it |
|
||||||
| Testing | Golden EXR fixture sets: complete, gapped, duplicate, wrong-res, wrong-depth, no-alpha, corrupt, zero-byte; checksum determinism; large-sequence perf (500 frames < 60 s) |
|
| Testing | Golden EXR fixture sets: complete, gapped, duplicate, wrong-res, wrong-depth, no-alpha, corrupt, zero-byte; checksum determinism; large-sequence perf (500 frames < 60 s) |
|
||||||
| Migration | Additive; existing in-flight exports unaffected (transition map keys off manifest version) |
|
| Migration | Additive; existing in-flight exports unaffected (transition map keys off manifest version) |
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
publish/
|
publish/
|
||||||
*.user
|
*.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
|
||||||
|
|||||||
+86
-2
@@ -60,8 +60,82 @@ the artist's AEP (then it is pure `aerender`), or the ffmpeg engine.
|
|||||||
are never resumed — the next attempt clears its own output files and re-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 —
|
- Durable reporting: complete/fail reports spool to disk and replay in order with backoff —
|
||||||
rendering continues while the server is down.
|
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.
|
||||||
|
|
||||||
Preview generation (Phase 4) and validation (Phase 3) plug into this same service later.
|
## 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
|
## Packaging for another machine
|
||||||
|
|
||||||
@@ -117,14 +191,24 @@ Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8):
|
|||||||
"aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe",
|
"aerenderPath": "C:\\Program Files\\Adobe\\Adobe After Effects 2026\\Support Files\\aerender.exe",
|
||||||
"aeVersion": "24.3",
|
"aeVersion": "24.3",
|
||||||
"ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe",
|
"ffmpegPath": "C:\\pipeline\\bin\\ffmpeg.exe",
|
||||||
|
"blenderPath": "C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe",
|
||||||
|
"plateSearchRoots": ["V:/_FOOTAGE/UNG"],
|
||||||
"pathMappings": [
|
"pathMappings": [
|
||||||
{ "from": "//SAN/", "to": "S:/" }
|
{ "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
|
`pathMappings` translate the manifest's canonical UNC paths to this machine's drive
|
||||||
mappings. Everything tunable (poll interval, lease, stall timeout, max attempts) lives in
|
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.
|
the server's SystemConfig and arrives at registration — no per-machine tuning files.
|
||||||
|
|
||||||
## Run interactively (first-time smoke test)
|
## Run interactively (first-time smoke test)
|
||||||
|
|||||||
@@ -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,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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,20 @@ public sealed class ApiClient
|
|||||||
hostname = Dns.GetHostName(),
|
hostname = Dns.GetHostName(),
|
||||||
workerVersion = o.WorkerVersion,
|
workerVersion = o.WorkerVersion,
|
||||||
aeVersion = o.AeVersion,
|
aeVersion = o.AeVersion,
|
||||||
capabilities = new { maxConcurrentJobs = 1, tools = new { ffmpeg = o.FfmpegPath != null, oiiotool = o.OiiotoolPath != null } },
|
// 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);
|
}, Json, ct);
|
||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
return (await res.Content.ReadFromJsonAsync<RegisterResponse>(Json, ct))!;
|
return (await res.Content.ReadFromJsonAsync<RegisterResponse>(Json, ct))!;
|
||||||
@@ -71,9 +84,14 @@ public sealed class ApiClient
|
|||||||
return body?.Job;
|
return body?.Job;
|
||||||
}
|
}
|
||||||
|
|
||||||
// E9 — best-effort; failures are swallowed by the caller (lease/reaper covers us)
|
// 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,
|
public async Task<ProgressResponse?> ProgressAsync(string jobId, string machineId, double progress,
|
||||||
int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct)
|
int? currentFrame, int? totalFrames, int? etaSeconds, string? logTail, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress")
|
using var req = new HttpRequestMessage(HttpMethod.Patch, $"api/ext/render/jobs/{jobId}/progress")
|
||||||
{
|
{
|
||||||
@@ -83,6 +101,16 @@ public sealed class ApiClient
|
|||||||
if (!res.IsSuccessStatusCode) return null;
|
if (!res.IsSuccessStatusCode) return null;
|
||||||
return await res.Content.ReadFromJsonAsync<ProgressResponse>(Json, ct);
|
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.
|
// E10 / E11 — lifecycle reports, sent via the spooler for durability.
|
||||||
// Returns true when the server accepted (2xx) OR permanently rejected (409
|
// Returns true when the server accepted (2xx) OR permanently rejected (409
|
||||||
@@ -93,16 +121,22 @@ public sealed class ApiClient
|
|||||||
{
|
{
|
||||||
"fail" => $"api/ext/render/jobs/{jobId}/fail",
|
"fail" => $"api/ext/render/jobs/{jobId}/fail",
|
||||||
"complete-render" => $"api/ext/render/jobs/{jobId}/complete-render",
|
"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",
|
"finalize" => $"api/ext/render/jobs/{jobId}/finalize",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"),
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"),
|
||||||
};
|
};
|
||||||
var res = await _http.PostAsJsonAsync(path, payload, Json, ct);
|
var res = await _http.PostAsJsonAsync(path, payload, Json, ct);
|
||||||
if (res.IsSuccessStatusCode) return true;
|
if (res.IsSuccessStatusCode) return true;
|
||||||
if (res.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.NotFound)
|
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}",
|
_log.LogWarning("Server permanently rejected {Kind} for job {JobId}: {Status} {Body}",
|
||||||
kind, jobId, (int)res.StatusCode, await res.Content.ReadAsStringAsync(ct));
|
kind, jobId, (int)res.StatusCode, await res.Content.ReadAsStringAsync(ct));
|
||||||
return true; // don't retry contradictory/vanished reports
|
return true;
|
||||||
}
|
}
|
||||||
_log.LogWarning("Lifecycle report {Kind} for {JobId} failed with {Status}; will retry", kind, jobId, (int)res.StatusCode);
|
_log.LogWarning("Lifecycle report {Kind} for {JobId} failed with {Status}; will retry", kind, jobId, (int)res.StatusCode);
|
||||||
return false;
|
return false;
|
||||||
@@ -159,6 +193,36 @@ public sealed class ApiClient
|
|||||||
public Task<string?> UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) =>
|
public Task<string?> UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) =>
|
||||||
UploadArtifactAsync(jobId, machineId, "log", logFilePath, "text/plain", 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)
|
// E3 — used by crash-recovery reconcile (§7.5)
|
||||||
public async Task<ExportDetail?> GetExportAsync(string exportId, CancellationToken ct)
|
public async Task<ExportDetail?> GetExportAsync(string exportId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,15 @@ public sealed class ServerConfig
|
|||||||
public int LeaseSeconds { get; set; } = 300;
|
public int LeaseSeconds { get; set; } = 300;
|
||||||
public int MaxAttempts { get; set; } = 3;
|
public int MaxAttempts { get; set; } = 3;
|
||||||
public int StallTimeoutSeconds { get; set; } = 600;
|
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 sealed class ClaimResponse
|
||||||
@@ -40,6 +49,9 @@ public sealed class ClaimedJob
|
|||||||
public int Attempt { get; set; }
|
public int Attempt { get; set; }
|
||||||
public int MaxAttempts { get; set; }
|
public int MaxAttempts { get; set; }
|
||||||
public string? ExportId { 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; }
|
public DateTimeOffset? LeaseExpiresAt { get; set; }
|
||||||
|
|
||||||
/// <summary>Manifest shape depends on the job type — deserialize per stage.</summary>
|
/// <summary>Manifest shape depends on the job type — deserialize per stage.</summary>
|
||||||
@@ -52,6 +64,93 @@ public sealed class ClaimedJob
|
|||||||
public PreviewManifest AsPreviewManifest() =>
|
public PreviewManifest AsPreviewManifest() =>
|
||||||
Manifest.Deserialize<PreviewManifest>(WorkerOptions.JsonOpts)
|
Manifest.Deserialize<PreviewManifest>(WorkerOptions.JsonOpts)
|
||||||
?? throw new InvalidOperationException("Job manifest is not a preview manifest");
|
?? 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>
|
/// <summary>PREVIEW_ONLY manifest (server-built, RenderPipeline2 §9).</summary>
|
||||||
@@ -88,6 +187,8 @@ public sealed class SlateInfo
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string ShotCode { 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? Episode { get; set; }
|
||||||
public string? Scene { get; set; }
|
public string? Scene { get; set; }
|
||||||
public string? VfxScope { get; set; }
|
public string? VfxScope { get; set; }
|
||||||
@@ -105,19 +206,39 @@ public sealed class PreviewBuildResult
|
|||||||
public int? FrameCount { get; set; }
|
public int? FrameCount { get; set; }
|
||||||
public int? Width { get; set; }
|
public int? Width { get; set; }
|
||||||
public int? Height { 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) — only the fields the Phase 2 render stage needs; the rest is preserved raw.</summary>
|
/// <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 sealed class RenderManifest
|
||||||
{
|
{
|
||||||
|
public string ShotCode { get; set; } = "";
|
||||||
public string AepPath { get; set; } = "";
|
public string AepPath { get; set; } = "";
|
||||||
public string CompName { get; set; } = "";
|
public string CompName { get; set; } = "";
|
||||||
public string OutputDir { get; set; } = "";
|
public string OutputDir { get; set; } = "";
|
||||||
public string OutputPattern { get; set; } = "";
|
public string OutputPattern { get; set; } = "";
|
||||||
public string? OutputModuleTemplate { get; set; }
|
public string? OutputModuleTemplate { get; set; }
|
||||||
public string? RenderSettingsTemplate { get; set; }
|
public string? RenderSettingsTemplate { get; set; }
|
||||||
|
/// <summary>Comp timeline range handed to aerender (-s/-e).</summary>
|
||||||
public int FrameStart { get; set; }
|
public int FrameStart { get; set; }
|
||||||
public int FrameEnd { 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]
|
[JsonExtensionData]
|
||||||
public Dictionary<string, JsonElement>? Extra { get; set; }
|
public Dictionary<string, JsonElement>? Extra { get; set; }
|
||||||
@@ -125,6 +246,35 @@ public sealed class RenderManifest
|
|||||||
public int TotalFrames => FrameEnd - FrameStart + 1;
|
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 sealed class ProgressResponse
|
||||||
{
|
{
|
||||||
public bool Ok { get; set; }
|
public bool Ok { 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}";
|
||||||
|
}
|
||||||
@@ -14,15 +14,22 @@ public sealed class JobExecutor
|
|||||||
private readonly ApiClient _api;
|
private readonly ApiClient _api;
|
||||||
private readonly AerenderRunner _runner;
|
private readonly AerenderRunner _runner;
|
||||||
private readonly PreviewStage _preview;
|
private readonly PreviewStage _preview;
|
||||||
|
private readonly ValidationStage _validation;
|
||||||
|
private readonly DeliveryStage _delivery;
|
||||||
|
private readonly BlenderRunner _blender;
|
||||||
private readonly ReportSpooler _spooler;
|
private readonly ReportSpooler _spooler;
|
||||||
private readonly ILogger<JobExecutor> _log;
|
private readonly ILogger<JobExecutor> _log;
|
||||||
|
|
||||||
public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview, ReportSpooler spooler, 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;
|
_options = options;
|
||||||
_api = api;
|
_api = api;
|
||||||
_runner = runner;
|
_runner = runner;
|
||||||
_preview = preview;
|
_preview = preview;
|
||||||
|
_validation = validation;
|
||||||
|
_delivery = delivery;
|
||||||
|
_blender = blender;
|
||||||
_spooler = spooler;
|
_spooler = spooler;
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
@@ -35,6 +42,18 @@ public sealed class JobExecutor
|
|||||||
return;
|
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 manifest = job.AsRenderManifest();
|
||||||
var mapper = new PathMapper(_options.PathMappings);
|
var mapper = new PathMapper(_options.PathMappings);
|
||||||
var outputDirLocal = mapper.Map(manifest.OutputDir);
|
var outputDirLocal = mapper.Map(manifest.OutputDir);
|
||||||
@@ -101,6 +120,15 @@ public sealed class JobExecutor
|
|||||||
["exrFileCount"] = fileCount,
|
["exrFileCount"] = fileCount,
|
||||||
["exrTotalBytes"] = totalBytes,
|
["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")
|
else if (result.ErrorMessage == "Cancelled")
|
||||||
{
|
{
|
||||||
@@ -149,6 +177,208 @@ public sealed class JobExecutor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate
|
/// PREVIEW_ONLY job: rebuild the shot around the rendered EXRs with slate
|
||||||
/// and burn-ins, render the delivery MOV + review MP4, upload the review
|
/// and burn-ins, render the delivery MOV + review MP4, upload the review
|
||||||
|
|||||||
@@ -31,12 +31,18 @@ public static class Preflight
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($" worker version : {options.WorkerVersion}");
|
||||||
Console.WriteLine($" machine name : {options.MachineName}");
|
Console.WriteLine($" machine name : {options.MachineName}");
|
||||||
Console.WriteLine($" server : {options.ServerUrl}");
|
Console.WriteLine($" server : {options.ServerUrl}");
|
||||||
Console.WriteLine($" api key : {(string.IsNullOrWhiteSpace(options.ApiKey) ? "(missing)" : "set")}");
|
Console.WriteLine($" api key : {(string.IsNullOrWhiteSpace(options.ApiKey) ? "(missing)" : "set")}");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
|
|
||||||
ok &= Report("aerender.exe", options.AerenderPath, File.Exists(options.AerenderPath));
|
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();
|
var afterFx = options.ResolveAfterFxPath();
|
||||||
ok &= Report("AfterFX.com", afterFx, File.Exists(afterFx),
|
ok &= Report("AfterFX.com", afterFx, File.Exists(afterFx),
|
||||||
"needed for preview builds only");
|
"needed for preview builds only");
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ public sealed class PreviewStage
|
|||||||
null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds);
|
null, null, null, build.TempAep, build, sw.Elapsed.TotalSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
var thumbnail = await TryMakeThumbnailAsync(job.Id, mp4Local, shutdown);
|
var thumbnail = await TryMakeThumbnailAsync(job.Id, mp4Local, build, manifest.Fps, shutdown);
|
||||||
|
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
_log.LogInformation("Preview complete for job {JobId} in {Sec:F0}s (mov + mp4{Thumb})",
|
_log.LogInformation("Preview complete for job {JobId} in {Sec:F0}s (mov + mp4{Thumb})",
|
||||||
@@ -231,14 +231,96 @@ public sealed class PreviewStage
|
|||||||
return process.ExitCode;
|
return process.ExitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Optional 960-wide poster frame at 25% duration (§9.1). Never fatal.</summary>
|
/// <summary>
|
||||||
private async Task<string?> TryMakeThumbnailAsync(string jobId, string mp4Path, CancellationToken ct)
|
/// 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) || !File.Exists(_options.FfmpegPath))
|
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;
|
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 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);
|
TryDelete(outPath);
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
@@ -249,27 +331,102 @@ public sealed class PreviewStage
|
|||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
};
|
};
|
||||||
foreach (var a in new[] { "-y", "-i", mp4Path, "-vf", "thumbnail,scale=960:-1", "-frames:v", "1", outPath })
|
|
||||||
|
var args = new List<string> { "-y" };
|
||||||
|
if (seekSeconds is { } t)
|
||||||
{
|
{
|
||||||
psi.ArgumentList.Add(a);
|
// 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
|
try
|
||||||
{
|
{
|
||||||
using var process = Process.Start(psi);
|
using var process = Process.Start(psi);
|
||||||
if (process is null) return null;
|
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);
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
timeout.CancelAfter(TimeSpan.FromMinutes(2));
|
timeout.CancelAfter(TimeSpan.FromMinutes(2));
|
||||||
|
try
|
||||||
|
{
|
||||||
await process.WaitForExitAsync(timeout.Token);
|
await process.WaitForExitAsync(timeout.Token);
|
||||||
return File.Exists(outPath) ? outPath : null;
|
}
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogWarning(ex, "Thumbnail generation failed (non-fatal)");
|
_log.LogWarning(ex, "Thumbnail generation failed for job {JobId} (non-fatal)", jobId);
|
||||||
return null;
|
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)
|
private static PreviewStageResult Fail(string message, bool retryable, Stopwatch sw, PreviewBuildResult? build = null)
|
||||||
{
|
{
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ using VFXReviewWorker;
|
|||||||
// Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json]
|
// Run interactively for debugging: VFXReviewWorker.exe [path\to\config.json]
|
||||||
// Install as a service: see RenderWorker/README.md
|
// 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('-'));
|
var configPath = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||||
|
|
||||||
// Install verification on a new machine — resolves paths, checks tools and
|
// Install verification on a new machine — resolves paths, checks tools and
|
||||||
@@ -32,6 +39,9 @@ builder.Services.AddSingleton<ApiClient>();
|
|||||||
builder.Services.AddSingleton<ReportSpooler>();
|
builder.Services.AddSingleton<ReportSpooler>();
|
||||||
builder.Services.AddSingleton<AerenderRunner>();
|
builder.Services.AddSingleton<AerenderRunner>();
|
||||||
builder.Services.AddSingleton<PreviewStage>();
|
builder.Services.AddSingleton<PreviewStage>();
|
||||||
|
builder.Services.AddSingleton<ValidationStage>();
|
||||||
|
builder.Services.AddSingleton<DeliveryStage>();
|
||||||
|
builder.Services.AddSingleton<BlenderRunner>();
|
||||||
builder.Services.AddSingleton<JobExecutor>();
|
builder.Services.AddSingleton<JobExecutor>();
|
||||||
builder.Services.AddHostedService<WorkerService>();
|
builder.Services.AddHostedService<WorkerService>();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ namespace VFXReviewWorker;
|
|||||||
/// order with exponential backoff (1 s → 60 s). Rendering continues during
|
/// order with exponential backoff (1 s → 60 s). Rendering continues during
|
||||||
/// server downtime; a report is deleted only once the server accepts it (or
|
/// server downtime; a report is deleted only once the server accepts it (or
|
||||||
/// permanently rejects it as contradictory).
|
/// 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>
|
/// </summary>
|
||||||
public sealed class ReportSpooler
|
public sealed class ReportSpooler
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
<AssemblyName>VFXReviewWorker</AssemblyName>
|
<AssemblyName>VFXReviewWorker</AssemblyName>
|
||||||
<!-- Single self-contained exe for render nodes: dotnet publish -r win-x64 (see README) -->
|
<!-- Single self-contained exe for render nodes: dotnet publish -r win-x64 (see README) -->
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -20,9 +28,16 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Headless AE build script ships beside the exe -->
|
<!--
|
||||||
|
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">
|
<Content Include="..\scripts\vfxr_build_preview.jsx" Link="scripts\vfxr_build_preview.jsx">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -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}";
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
@@ -19,9 +20,32 @@ public sealed class WorkerOptions
|
|||||||
/// <summary>vfxr_build_preview.jsx — defaults to scripts\ beside the worker exe.</summary>
|
/// <summary>vfxr_build_preview.jsx — defaults to scripts\ beside the worker exe.</summary>
|
||||||
public string? PreviewScriptPath { get; set; }
|
public string? PreviewScriptPath { get; set; }
|
||||||
public string? FfmpegPath { 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 string? OiiotoolPath { get; set; }
|
||||||
public List<PathMapping> PathMappings { get; set; } = new();
|
public List<PathMapping> PathMappings { get; set; } = new();
|
||||||
public string WorkerVersion { get; set; } = "1.0.0";
|
/// <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 string? AeVersion { get; set; }
|
||||||
|
|
||||||
public static string DataDir =>
|
public static string DataDir =>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public sealed class WorkerService : BackgroundService
|
|||||||
_ = Task.Run(() => _spooler.RunAsync(stoppingToken), stoppingToken);
|
_ = Task.Run(() => _spooler.RunAsync(stoppingToken), stoppingToken);
|
||||||
|
|
||||||
await RegisterWithRetryAsync(stoppingToken);
|
await RegisterWithRetryAsync(stoppingToken);
|
||||||
|
await _api.ReportVersionAsync(_options, stoppingToken);
|
||||||
await _executor.ReconcileAsync(_machineId!, stoppingToken);
|
await _executor.ReconcileAsync(_machineId!, stoppingToken);
|
||||||
|
|
||||||
_ = Task.Run(() => HeartbeatLoopAsync(stoppingToken), stoppingToken);
|
_ = Task.Run(() => HeartbeatLoopAsync(stoppingToken), stoppingToken);
|
||||||
@@ -61,9 +62,13 @@ public sealed class WorkerService : BackgroundService
|
|||||||
: "No interactive After Effects — preview jobs enabled");
|
: "No interactive After Effects — preview jobs enabled");
|
||||||
_lastAeOpen = aeOpen;
|
_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
|
var types = aeOpen
|
||||||
? new[] { "AE_RENDER" }
|
? new[] { "AE_RENDER", "DELIVERY_BUILD", "BLENDER_RENDER" }
|
||||||
: new[] { "AE_RENDER", "PREVIEW_ONLY" };
|
: new[] { "AE_RENDER", "PREVIEW_ONLY", "DELIVERY_BUILD", "BLENDER_RENDER" };
|
||||||
|
|
||||||
var job = await _api.ClaimAsync(_machineId!, types, stoppingToken);
|
var job = await _api.ClaimAsync(_machineId!, types, stoppingToken);
|
||||||
if (job is not null)
|
if (job is not null)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,34 @@ $ErrorActionPreference = "Stop"
|
|||||||
$root = Split-Path $PSScriptRoot -Parent
|
$root = Split-Path $PSScriptRoot -Parent
|
||||||
if (-not $OutDir) { $OutDir = Join-Path $root "publish\win-x64" }
|
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
|
Write-Host "Publishing self-contained worker..." -ForegroundColor Cyan
|
||||||
& dotnet publish (Join-Path $root "VFXReviewWorker\VFXReviewWorker.csproj") `
|
$publishArgs = @(
|
||||||
-c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o $OutDir
|
(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" }
|
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" }
|
||||||
|
|
||||||
# The exe reads scripts\vfxr_build_preview.jsx from its own folder at runtime.
|
# 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"
|
$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" }
|
if (-not (Test-Path $scriptOut)) { throw "Preview build script missing from output: $scriptOut" }
|
||||||
|
|
||||||
Remove-Item (Join-Path $OutDir "*.pdb") -ErrorAction SilentlyContinue
|
Remove-Item (Join-Path $OutDir "*.pdb") -ErrorAction SilentlyContinue
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
and then quit it.
|
and then quit it.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\UNG\renders\UNG_S1\111\UNG_111_001_030\v002" `
|
.\test-preview-build.ps1 -ExrDir "V:\_EXPORTS\UNG_S1\111\UNG_111_001_030\v002" `
|
||||||
-ShotCode "UNG_111_001_030" -Version "v002"
|
-ShotCode "UNG_111_001_030" -Version "v002"
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
|
|||||||
@@ -30,7 +30,11 @@
|
|||||||
queued: [],
|
queued: [],
|
||||||
frameCount: null,
|
frameCount: null,
|
||||||
width: null,
|
width: null,
|
||||||
height: 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;
|
var resultPath = null;
|
||||||
|
|
||||||
@@ -230,6 +234,26 @@
|
|||||||
return names;
|
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
|
// Per-submission fields (VFX Scope / Submission Note). The property names
|
||||||
// come from config; if one does not exist, report the template's actual
|
// come from config; if one does not exist, report the template's actual
|
||||||
// property names so it can be corrected without another build.
|
// property names so it can be corrected without another build.
|
||||||
@@ -246,6 +270,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function setCompStartFrame(comp, frameNumber) {
|
||||||
try {
|
try {
|
||||||
comp.displayStartFrame = frameNumber;
|
comp.displayStartFrame = frameNumber;
|
||||||
@@ -417,6 +462,7 @@
|
|||||||
overlayLayer.enabled = true;
|
overlayLayer.enabled = true;
|
||||||
setEssentialProperty(overlayLayer, "DATE", ctx.slate.date);
|
setEssentialProperty(overlayLayer, "DATE", ctx.slate.date);
|
||||||
setEssentialProperty(overlayLayer, "SHOT NAME", ctx.slate.versionName);
|
setEssentialProperty(overlayLayer, "SHOT NAME", ctx.slate.versionName);
|
||||||
|
setShowField(overlayLayer, ctx.slate.show, "Overlay SHOW");
|
||||||
} else {
|
} else {
|
||||||
warn("Overlay comp \"" + ctx.overlayComp + "\" not found — rendering without burn-ins");
|
warn("Overlay comp \"" + ctx.overlayComp + "\" not found — rendering without burn-ins");
|
||||||
}
|
}
|
||||||
@@ -439,15 +485,19 @@
|
|||||||
|
|
||||||
var slateLayer = previewComp.layer("NETFLIX_SLATE");
|
var slateLayer = previewComp.layer("NETFLIX_SLATE");
|
||||||
if (slateLayer) {
|
if (slateLayer) {
|
||||||
// Index-addressed properties match the panel exactly (3 = version
|
// Identity fields match the panel's indices (3 = version name,
|
||||||
// name, 10 = shot code); the rest are addressed by name.
|
// 10 = shot name) and are also written by name; the rest are
|
||||||
setEssentialPropertyByIndex(slateLayer, 3, ctx.slate.versionName);
|
// 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, "Date", ctx.slate.date);
|
||||||
setEssentialProperty(slateLayer, "Desc", ctx.slate.description);
|
setEssentialProperty(slateLayer, "Desc", ctx.slate.description);
|
||||||
setEssentialPropertyByIndex(slateLayer, 10, ctx.slate.shotCode);
|
|
||||||
setEssentialProperty(slateLayer, "Episode", ctx.slate.episode);
|
setEssentialProperty(slateLayer, "Episode", ctx.slate.episode);
|
||||||
setEssentialProperty(slateLayer, "Scene", ctx.slate.scene);
|
setEssentialProperty(slateLayer, "Scene", ctx.slate.scene);
|
||||||
setEssentialProperty(slateLayer, "Frames", result.frameCount);
|
setEssentialProperty(slateLayer, "Frames", result.frameCount);
|
||||||
|
setShowField(slateLayer, ctx.slate.show, "Slate SHOW");
|
||||||
|
|
||||||
setSlateField(slateLayer, ctx.slateScopeProp, ctx.slate.vfxScope, "VFX Scope");
|
setSlateField(slateLayer, ctx.slateScopeProp, ctx.slate.vfxScope, "VFX Scope");
|
||||||
// The slate's Notes field carries this submission's note; when the
|
// The slate's Notes field carries this submission's note; when the
|
||||||
@@ -466,6 +516,8 @@
|
|||||||
|
|
||||||
var shotLayer = previewComp.layer("SHOT");
|
var shotLayer = previewComp.layer("SHOT");
|
||||||
var targetOutPoint = shotLayer.inPoint + shotComp.duration;
|
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) {
|
if (targetOutPoint > previewComp.duration) {
|
||||||
previewComp.duration = targetOutPoint;
|
previewComp.duration = targetOutPoint;
|
||||||
}
|
}
|
||||||
|
|||||||
+2399
-243
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
|
## 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 |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -21,13 +175,15 @@ At the top of the script, four global variables control how the panel connects t
|
|||||||
|
|
||||||
## Panel Layout
|
## 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
|
1. **Shot Builder** — Episode/shot selection and comp creation
|
||||||
2. **Actions** — Per-shot and batch operations
|
2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews
|
||||||
3. **Workspace** — One-time project initialisation
|
3. **QC** — Technical QC queue for rendered exports
|
||||||
4. **Color Space** — OCIO effect management
|
4. **Actions** — Per-shot and batch operations
|
||||||
5. **Status** — Live feedback on selected comps and last action
|
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:**
|
**Process:**
|
||||||
1. Calls the shot lookup API to retrieve metadata (description, notes, episode, scene, etc.).
|
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.
|
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).
|
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.
|
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.
|
6. Anchors both comps on the plate's start timecode — see [Start timecode](#start-timecode).
|
||||||
7. Calls **Add Overlay** logic to attach the `UNG_VFX_OVERLAY` comp.
|
7. If a `_SHOW LUT` comp exists in the project, adds it as a collapsed layer on top.
|
||||||
8. Organises items into `__SHOTS/{episodeCode}`, `_FOOTAGE_4K`, and `_PRECOMPS` project folders.
|
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
|
### Fix Comp Names
|
||||||
Renames selected comps so their names match the shot code extracted from their current name.
|
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.
|
- 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.
|
- 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:**
|
**Process:**
|
||||||
1. Calls the API to retrieve `seqTimecodeStart` and `seqTimecodeEnd` for the shot.
|
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.
|
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**.
|
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.
|
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
|
## 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
|
### Initialise Workspace
|
||||||
One-time setup that creates the standard project folder hierarchy, imports the slate/overlay template AEP, and configures color settings.
|
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:**
|
**Folder structure created:**
|
||||||
|
|
||||||
```
|
```
|
||||||
__SHOTS/
|
__SHOTS/
|
||||||
UNG_101/
|
{EPISODE_CODE}/ ← one per episode, from the project's episode list
|
||||||
UNG_102/
|
… (episodic shows only; standard shows get none)
|
||||||
…
|
|
||||||
UNG_117/
|
|
||||||
_PREVIEWS/
|
_PREVIEWS/
|
||||||
_PRECOMPS/
|
_PRECOMPS/
|
||||||
_PICLOCKS/
|
_PICLOCKS/
|
||||||
@@ -192,8 +434,15 @@ _RENDERS/
|
|||||||
_UTILITIES/
|
_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:**
|
**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):**
|
**Color settings (applied outside the undo group):**
|
||||||
- Sets project bit depth to **32-bit**.
|
- Sets project bit depth to **32-bit**.
|
||||||
@@ -246,15 +495,19 @@ Accept: application/json
|
|||||||
|
|
||||||
## Shot Code Convention
|
## Shot Code Convention
|
||||||
|
|
||||||
Shot codes follow the pattern `AAA_NNN_NNN_NNN` where:
|
Shot codes are the project's **showId** followed by three-digit tokens:
|
||||||
- `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`)
|
|
||||||
|
|
||||||
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.
|
- **Line 2** — Result or progress message from the last operation.
|
||||||
|
|
||||||
Both lines are updated throughout batch operations so progress is visible in real time.
|
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.
|
||||||
|
|||||||
+24
-14
@@ -5,7 +5,7 @@ import { db } from "@/lib/db";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ApprovalStatus } from "@prisma/client";
|
import { ApprovalStatus } from "@prisma/client";
|
||||||
import { recalcShotStatus } from "@/lib/shot-status";
|
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
|
||||||
import { notifyApprovalChange } from "@/lib/notifications";
|
import { notifyApprovalChange } from "@/lib/notifications";
|
||||||
import { slackNotifyApproval } from "@/lib/slack";
|
import { slackNotifyApproval } from "@/lib/slack";
|
||||||
import { versionLabel } from "@/lib/utils";
|
import { versionLabel } from "@/lib/utils";
|
||||||
@@ -29,8 +29,11 @@ export async function submitApproval(data: z.infer<typeof approvalSchema>) {
|
|||||||
throw new Error("Insufficient permissions to review versions");
|
throw new Error("Insufficient permissions to review versions");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the approval record
|
// The verdict, the version, the task and the derived shot status are one
|
||||||
const approval = await db.approval.create({
|
// 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: {
|
data: {
|
||||||
versionId: parsed.versionId,
|
versionId: parsed.versionId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -39,14 +42,13 @@ export async function submitApproval(data: z.infer<typeof approvalSchema>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update version approval status
|
await tx.version.update({
|
||||||
await db.version.update({
|
|
||||||
where: { id: parsed.versionId },
|
where: { id: parsed.versionId },
|
||||||
data: { approvalStatus: parsed.status },
|
data: { approvalStatus: parsed.status },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load version + task + shot + project for downstream effects
|
// Load version + task + shot + project for downstream effects
|
||||||
const version = await db.version.findUnique({
|
const version = await tx.version.findUnique({
|
||||||
where: { id: parsed.versionId },
|
where: { id: parsed.versionId },
|
||||||
include: {
|
include: {
|
||||||
task: {
|
task: {
|
||||||
@@ -63,12 +65,12 @@ export async function submitApproval(data: z.infer<typeof approvalSchema>) {
|
|||||||
// Update task status based on approval result
|
// Update task status based on approval result
|
||||||
if (version.task && parsed.status !== "PENDING_REVIEW") {
|
if (version.task && parsed.status !== "PENDING_REVIEW") {
|
||||||
if (parsed.status === "APPROVED") {
|
if (parsed.status === "APPROVED") {
|
||||||
await db.task.update({
|
await tx.task.update({
|
||||||
where: { id: version.task.id },
|
where: { id: version.task.id },
|
||||||
data: { status: "DONE" },
|
data: { status: "DONE" },
|
||||||
});
|
});
|
||||||
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
|
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
|
||||||
await db.task.update({
|
await tx.task.update({
|
||||||
where: { id: version.task.id },
|
where: { id: version.task.id },
|
||||||
data: { status: "CHANGES" },
|
data: { status: "CHANGES" },
|
||||||
});
|
});
|
||||||
@@ -76,10 +78,13 @@ export async function submitApproval(data: z.infer<typeof approvalSchema>) {
|
|||||||
|
|
||||||
// Recalculate shot status from updated task states
|
// Recalculate shot status from updated task states
|
||||||
if (version.task.shot) {
|
if (version.task.shot) {
|
||||||
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
await recalcShotStatus(version.task.shot.id, tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { approval, version };
|
||||||
|
});
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
const reviewer = await db.user.findUnique({ where: { id: session.user.id } });
|
const reviewer = await db.user.findUnique({ where: { id: session.user.id } });
|
||||||
const reviewerName = reviewer?.name ?? "Reviewer";
|
const reviewerName = reviewer?.name ?? "Reviewer";
|
||||||
@@ -166,8 +171,10 @@ export async function unapproveVersion(versionId: string) {
|
|||||||
throw new Error("Only approved versions can be unapproved");
|
throw new Error("Only approved versions can be unapproved");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One decision, one write — same reasoning as submitApproval.
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
// Create an audit record for the unapproval
|
// Create an audit record for the unapproval
|
||||||
await db.approval.create({
|
await tx.approval.create({
|
||||||
data: {
|
data: {
|
||||||
versionId,
|
versionId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -177,23 +184,26 @@ export async function unapproveVersion(versionId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reset version approval status
|
// Reset version approval status
|
||||||
await db.version.update({
|
await tx.version.update({
|
||||||
where: { id: versionId },
|
where: { id: versionId },
|
||||||
data: { approvalStatus: "PENDING_REVIEW" },
|
data: { approvalStatus: "PENDING_REVIEW" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset task back to INTERNAL_REVIEW if it was moved to DONE
|
// Reset task back to INTERNAL_REVIEW if it was moved to DONE
|
||||||
if (version.task && version.task.status === "DONE") {
|
if (version.task && version.task.status === "DONE") {
|
||||||
await db.task.update({
|
await tx.task.update({
|
||||||
where: { id: version.task.id },
|
where: { id: version.task.id },
|
||||||
data: { status: "INTERNAL_REVIEW" },
|
data: { status: "INTERNAL_REVIEW" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate shot status
|
// 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) {
|
if (version.task?.shot) {
|
||||||
await recalcShotStatus(version.task.shot.id).catch(() => {});
|
await clearStaleShotApproval(tx, version.task.shot.id);
|
||||||
|
await recalcShotStatus(version.task.shot.id, tx);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath(`/review/${versionId}`);
|
revalidatePath(`/review/${versionId}`);
|
||||||
if (version.task) {
|
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 { db } from "@/lib/db";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
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({
|
const createProjectSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -76,6 +77,8 @@ const updateProjectSchema = z.object({
|
|||||||
startDate: z.string().optional().nullable(),
|
startDate: z.string().optional().nullable(),
|
||||||
slackWebhook: z.string().url().optional().or(z.literal("")).nullable(),
|
slackWebhook: z.string().url().optional().or(z.literal("")).nullable(),
|
||||||
slackChannel: z.string().optional().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>) {
|
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.startDate !== undefined && { startDate: rest.startDate ? new Date(rest.startDate) : null }),
|
||||||
...(rest.slackWebhook !== undefined && { slackWebhook: rest.slackWebhook || null }),
|
...(rest.slackWebhook !== undefined && { slackWebhook: rest.slackWebhook || null }),
|
||||||
...(rest.slackChannel !== undefined && { slackChannel: rest.slackChannel || 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,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+250
-40
@@ -4,8 +4,8 @@ import { auth } from "@/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ShotStatus, ShotPriority, ShotApprovalStatus } from "@prisma/client";
|
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
|
||||||
import { recalcShotStatus } from "@/lib/shot-status";
|
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
|
||||||
|
|
||||||
const createShotSchema = z.object({
|
const createShotSchema = z.object({
|
||||||
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
|
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 };
|
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(
|
export async function updateShotStatus(
|
||||||
shotId: string,
|
shotId: string,
|
||||||
status: ShotStatus
|
status: ShotStatus
|
||||||
@@ -489,6 +498,42 @@ export async function updateShotNotes(shotId: string, notes: string) {
|
|||||||
return { success: true };
|
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 ───────────────────────────────────────────────────────────
|
// ── Toggle Key Shot ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
|
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
|
||||||
@@ -514,6 +559,34 @@ export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
|
|||||||
return { success: true };
|
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) {
|
export async function deleteShot(shotId: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) throw new Error("Unauthorized");
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
@@ -579,6 +652,59 @@ export async function bulkUpdateDueDates(
|
|||||||
return { success: true, updated: shots.length };
|
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) {
|
export async function renameFootagePlate(plateId: string, label: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) throw new Error("Unauthorized");
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
@@ -620,14 +746,20 @@ export async function internallyApproveShot(shotId: string) {
|
|||||||
});
|
});
|
||||||
if (!shot) throw new Error("Shot not found");
|
if (!shot) throw new Error("Shot not found");
|
||||||
|
|
||||||
await db.shot.update({
|
// 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 },
|
where: { id: shotId },
|
||||||
data: {
|
data: {
|
||||||
shotApprovalStatus: "INTERNALLY_APPROVED",
|
shotApprovalStatus: "INTERNALLY_APPROVED",
|
||||||
sharedWithClient: false,
|
sharedWithClient: false,
|
||||||
status: "READY_FOR_CLIENT",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await recalcShotStatus(shotId, tx);
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath(`/projects/${shot.projectId}`);
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||||
@@ -669,7 +801,7 @@ export async function shareWithClient(shotId: string) {
|
|||||||
// Mark shot as shared
|
// Mark shot as shared
|
||||||
await tx.shot.update({
|
await tx.shot.update({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
data: { sharedWithClient: true, status: "CLIENT_REVIEW" },
|
data: { sharedWithClient: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// For each task, make the latest version client-visible
|
// 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}`);
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
@@ -721,7 +855,7 @@ export async function unshareFromClient(shotId: string) {
|
|||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
await tx.shot.update({
|
await tx.shot.update({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
data: { sharedWithClient: false, status: "READY_FOR_CLIENT" },
|
data: { sharedWithClient: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hide all client-visible versions on this shot's tasks
|
// Hide all client-visible versions on this shot's tasks
|
||||||
@@ -732,6 +866,8 @@ export async function unshareFromClient(shotId: string) {
|
|||||||
data: { isClientVisible: false },
|
data: { isClientVisible: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await recalcShotStatus(shotId, tx);
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/projects/${shot.projectId}`);
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
@@ -760,9 +896,12 @@ export async function clientApproveShot(shotId: string) {
|
|||||||
if (!shot) throw new Error("Shot not found");
|
if (!shot) throw new Error("Shot not found");
|
||||||
if (!shot.sharedWithClient) throw new Error("Shot is not shared with client");
|
if (!shot.sharedWithClient) throw new Error("Shot is not shared with client");
|
||||||
|
|
||||||
await db.shot.update({
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.shot.update({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
data: { shotApprovalStatus: "CLIENT_APPROVED" },
|
||||||
|
});
|
||||||
|
await recalcShotStatus(shotId, tx);
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/projects/${shot.projectId}`);
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
@@ -792,14 +931,15 @@ export async function unapproveShot(shotId: string) {
|
|||||||
throw new Error("Shot is not client-approved");
|
throw new Error("Shot is not client-approved");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newStatus = shot.sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT";
|
// The derivation already encodes "CLIENT_REVIEW if still shared, else
|
||||||
|
// READY_FOR_CLIENT" for an INTERNALLY_APPROVED shot, and additionally honours
|
||||||
await db.shot.update({
|
// any open CHANGES task.
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.shot.update({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
data: {
|
data: { shotApprovalStatus: "INTERNALLY_APPROVED" },
|
||||||
shotApprovalStatus: "INTERNALLY_APPROVED",
|
});
|
||||||
status: newStatus,
|
await recalcShotStatus(shotId, tx);
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/projects/${shot.projectId}`);
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
@@ -1100,9 +1240,12 @@ export async function assignShotGroups(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: handle client requesting changes on a shot.
|
* Session-authenticated wrapper for requesting changes on a shot — a supervisor
|
||||||
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
* or producer relaying a client note from inside the app.
|
||||||
* Recalculates shot status (will become REVISIONS if tasks set to CHANGES).
|
*
|
||||||
|
* 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) {
|
export async function clientRequestShotChanges(shotId: string, taskId?: string) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -1117,28 +1260,7 @@ export async function clientRequestShotChanges(shotId: string, taskId?: string)
|
|||||||
});
|
});
|
||||||
if (!shot) throw new Error("Shot not found");
|
if (!shot) throw new Error("Shot not found");
|
||||||
|
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction((tx) => markShotForRevisions(tx, shotId, taskId));
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath(`/projects/${shot.projectId}`);
|
revalidatePath(`/projects/${shot.projectId}`);
|
||||||
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||||
@@ -1177,3 +1299,91 @@ export async function updateShotVersion(shotId: string, version: string) {
|
|||||||
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
|
||||||
return { success: true, shotVersion: version };
|
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 { db } from "@/lib/db";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { recalcShotStatus } from "@/lib/shot-status";
|
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
|
||||||
import { notifyNewVersionUploaded } from "@/lib/notifications";
|
import { notifyNewVersionUploaded } from "@/lib/notifications";
|
||||||
import { slackNotifyVersionUploaded } from "@/lib/slack";
|
import { slackNotifyVersionUploaded } from "@/lib/slack";
|
||||||
|
|
||||||
@@ -50,9 +50,16 @@ export async function createVersion(data: z.infer<typeof createVersionSchema>) {
|
|||||||
data: { status: "INTERNAL_REVIEW" },
|
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) {
|
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
|
// Get the next version number for this task
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server } from "lucide-react";
|
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
@@ -112,6 +112,18 @@ export function PipelineQueueClient({ projects }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<Link href="/pipeline/machines">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Server className="h-4 w-4 mr-2" />
|
<Server className="h-4 w-4 mr-2" />
|
||||||
@@ -268,6 +280,124 @@ export function PipelineQueueClient({ projects }: Props) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</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)} />;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, RotateCcw, XCircle } from "lucide-react";
|
import { ArrowLeft, RotateCcw, XCircle, ShieldAlert } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -66,12 +67,49 @@ interface ExportDetail {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
createdAt: string;
|
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 }) {
|
export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["pipeline-export", exportId],
|
queryKey: ["pipeline-export", exportId],
|
||||||
@@ -92,6 +130,31 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
|
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 (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
return (
|
return (
|
||||||
@@ -145,6 +208,11 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
|||||||
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
|
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
|
||||||
</Button>
|
</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) && (
|
{ACTIVE_STATUSES.includes(e.status) && (
|
||||||
<Button variant="outline" size="sm" onClick={() => action("cancel")}>
|
<Button variant="outline" size="sm" onClick={() => action("cancel")}>
|
||||||
<XCircle className="h-4 w-4 mr-2 text-red-400" /> Cancel
|
<XCircle className="h-4 w-4 mr-2 text-red-400" /> Cancel
|
||||||
@@ -153,6 +221,64 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Manifest */}
|
||||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
<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>
|
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Manifest</div>
|
||||||
@@ -189,6 +315,100 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
|||||||
</div>
|
</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 */}
|
{/* Render attempts */}
|
||||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
<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">
|
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">
|
||||||
|
|||||||
@@ -201,6 +201,77 @@ export function MachinesClient() {
|
|||||||
})}
|
})}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ export const EXPORT_STATUS_STYLES: Record<string, string> = {
|
|||||||
CANCELLED: "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 FAILED_STATUSES = ["RENDER_FAILED", "VALIDATION_FAILED", "PREVIEW_FAILED", "QC_FAILED"];
|
||||||
export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"];
|
export const ACTIVE_STATUSES = ["QUEUED", "RENDERING", "VALIDATING", "GENERATING_PREVIEW"];
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ interface ProjectTabsClientProps {
|
|||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
slackWebhook: string | null;
|
slackWebhook: string | null;
|
||||||
slackChannel: 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[];
|
clients: Client[];
|
||||||
teamMembers: TeamMember[];
|
teamMembers: TeamMember[];
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default async function KanbanPage({
|
|||||||
where: { projectId: id },
|
where: { projectId: id },
|
||||||
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
||||||
include: {
|
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 } },
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
assignedArtist: {
|
assignedArtist: {
|
||||||
select: { id: true, name: true, email: true, image: true },
|
select: { id: true, name: true, email: true, image: true },
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async function getProject(id: string) {
|
|||||||
tasks: {
|
tasks: {
|
||||||
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
|
||||||
include: {
|
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 } },
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
|
||||||
_count: { select: { versions: true } },
|
_count: { select: { versions: true } },
|
||||||
@@ -201,6 +201,14 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
|||||||
startDate: project.startDate,
|
startDate: project.startDate,
|
||||||
slackWebhook: project.slackWebhook,
|
slackWebhook: project.slackWebhook,
|
||||||
slackChannel: project.slackChannel,
|
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}
|
clients={clients}
|
||||||
teamMembers={teamMembers}
|
teamMembers={teamMembers}
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ import {
|
|||||||
FileVideo,
|
FileVideo,
|
||||||
Pencil,
|
Pencil,
|
||||||
Check,
|
Check,
|
||||||
|
LayoutList,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import type { ShotWithDetails } from "@/types";
|
import type { ShotWithDetails } from "@/types";
|
||||||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||||||
import { ShotExportsTab } from "@/components/shots/ShotExportsTab";
|
import { ShotExportsTab } from "@/components/shots/ShotExportsTab";
|
||||||
|
import { ShotSlateTab } from "@/components/shots/ShotSlateTab";
|
||||||
import { FootageViewer } from "@/components/shots/FootageViewer";
|
import { FootageViewer } from "@/components/shots/FootageViewer";
|
||||||
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
|
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
|
||||||
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
|
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
|
||||||
@@ -74,6 +76,7 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
export default function ShotDetailPage() {
|
export default function ShotDetailPage() {
|
||||||
const params = useParams<{ id: string; shotId: string }>();
|
const params = useParams<{ id: string; shotId: string }>();
|
||||||
@@ -90,7 +93,7 @@ export default function ShotDetailPage() {
|
|||||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||||
const [isActioning, setIsActioning] = useState(false);
|
const [isActioning, setIsActioning] = useState(false);
|
||||||
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
|
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "exports" | "settings">("tasks");
|
const [activeTab, setActiveTab] = useState<"tasks" | "slate" | "reviews" | "footage" | "exports" | "settings">("tasks");
|
||||||
const [editingVersion, setEditingVersion] = useState(false);
|
const [editingVersion, setEditingVersion] = useState(false);
|
||||||
const [versionInput, setVersionInput] = useState("");
|
const [versionInput, setVersionInput] = useState("");
|
||||||
const [savingVersion, setSavingVersion] = useState(false);
|
const [savingVersion, setSavingVersion] = useState(false);
|
||||||
@@ -249,11 +252,11 @@ export default function ShotDetailPage() {
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{shot.thumbnailUrl && (
|
{shotThumbnail(shot) && (
|
||||||
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
|
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
src={shot.thumbnailUrl}
|
src={shotThumbnail(shot)!}
|
||||||
alt={shot.shotCode}
|
alt={shot.shotCode}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
@@ -520,6 +523,18 @@ export default function ShotDetailPage() {
|
|||||||
<ListTodo className="h-4 w-4" />
|
<ListTodo className="h-4 w-4" />
|
||||||
Tasks
|
Tasks
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setActiveTab("reviews")}
|
onClick={() => setActiveTab("reviews")}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -583,6 +598,10 @@ export default function ShotDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "slate" && (
|
||||||
|
<ShotSlateTab shot={shot} projectName={projectName} onSaved={fetchShot} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "reviews" && (
|
{activeTab === "reviews" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ export interface ScheduleTask {
|
|||||||
email: string;
|
email: string;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
} | 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;
|
asset: { id: string; assetCode: string; name: string } | null;
|
||||||
project: { id: string; name: string; code: string };
|
project: { id: string; name: string; code: string };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default async function SchedulePage({
|
|||||||
...(artist ? { assignedArtistId: artist } : {}),
|
...(artist ? { assignedArtistId: artist } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
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 } },
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
project: { select: { id: true, name: true, code: true } },
|
project: { select: { id: true, name: true, code: true } },
|
||||||
assignedArtist: {
|
assignedArtist: {
|
||||||
@@ -49,7 +49,7 @@ export default async function SchedulePage({
|
|||||||
...(artist ? { assignedArtistId: artist } : {}),
|
...(artist ? { assignedArtistId: artist } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
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 } },
|
asset: { select: { id: true, assetCode: true, name: true } },
|
||||||
project: { select: { id: true, name: true, code: true } },
|
project: { select: { id: true, name: true, code: true } },
|
||||||
assignedArtist: {
|
assignedArtist: {
|
||||||
|
|||||||
@@ -18,11 +18,19 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cn } from "@/lib/utils";
|
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 { useToast } from "@/components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
Film,
|
Film,
|
||||||
@@ -38,8 +46,13 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Eye,
|
Eye,
|
||||||
Star,
|
Star,
|
||||||
|
Trash2,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Clapperboard,
|
||||||
|
Scaling,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
type ShotRow = {
|
type ShotRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,9 +64,12 @@ type ShotRow = {
|
|||||||
priority: string;
|
priority: string;
|
||||||
dueDate: Date | string | null;
|
dueDate: Date | string | null;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
|
generatedThumbnailUrl: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
isKeyShot: boolean;
|
isKeyShot: boolean;
|
||||||
|
isScaled: boolean;
|
||||||
|
shotVersion: string | null;
|
||||||
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||||
shotGroup: { id: string; name: string } | null;
|
shotGroup: { id: string; name: string } | null;
|
||||||
};
|
};
|
||||||
@@ -209,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 ─────────────────────────────────────────────────────────────────
|
// ── NotesCell ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NotesCell({
|
function NotesCell({
|
||||||
@@ -264,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 [isPending, startTransition] = useTransition();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -276,10 +509,10 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) {
|
|||||||
setOptimistic(next);
|
setOptimistic(next);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await toggleKeyShot(shot.id, next);
|
await onSet(next);
|
||||||
} catch {
|
} catch {
|
||||||
setOptimistic(!next);
|
setOptimistic(!next);
|
||||||
toast({ title: "Failed to update key shot", variant: "destructive" });
|
toast({ title: failureTitle, variant: "destructive" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -289,12 +522,13 @@ function KeyShotToggle({ shot }: { shot: ShotRow }) {
|
|||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={optimistic}
|
aria-checked={optimistic}
|
||||||
aria-label={optimistic ? "Remove key shot" : "Mark as key shot"}
|
aria-label={optimistic ? offLabel : onLabel}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={handle}
|
onClick={handle}
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
optimistic ? "bg-amber-500" : "bg-zinc-600 hover:bg-zinc-500"
|
tone.ring,
|
||||||
|
optimistic ? tone.on : "bg-zinc-600 hover:bg-zinc-500"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -307,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({
|
function ShotTable({
|
||||||
shots,
|
shots,
|
||||||
canManage,
|
canManage,
|
||||||
@@ -349,6 +609,12 @@ function ShotTable({
|
|||||||
Key
|
Key
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-800/60">
|
<tbody className="divide-y divide-zinc-800/60">
|
||||||
@@ -382,9 +648,9 @@ function ShotTable({
|
|||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<td className="py-2 px-3">
|
<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">
|
<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
|
<Image
|
||||||
src={shot.thumbnailUrl}
|
src={shotThumbnail(shot)!}
|
||||||
alt={shot.shotCode}
|
alt={shot.shotCode}
|
||||||
width={96}
|
width={96}
|
||||||
height={40}
|
height={40}
|
||||||
@@ -409,6 +675,15 @@ function ShotTable({
|
|||||||
>
|
>
|
||||||
{shot.shotCode}
|
{shot.shotCode}
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
{shot.description && (
|
{shot.description && (
|
||||||
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
|
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
|
||||||
@@ -451,6 +726,11 @@ function ShotTable({
|
|||||||
<td className="py-2 px-3 text-center">
|
<td className="py-2 px-3 text-center">
|
||||||
<KeyShotToggle shot={shot} />
|
<KeyShotToggle shot={shot} />
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{/* Scaled-in-edit toggle */}
|
||||||
|
<td className="py-2 px-3 text-center">
|
||||||
|
<ScaledShotToggle shot={shot} />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -513,6 +793,8 @@ export function ShotStatusClient({
|
|||||||
}
|
}
|
||||||
}, [selectedProjectId]);
|
}, [selectedProjectId]);
|
||||||
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
|
||||||
|
|
||||||
const toggleEpisode = (ep: string) =>
|
const toggleEpisode = (ep: string) =>
|
||||||
setExpandedEpisodes((prev) => {
|
setExpandedEpisodes((prev) => {
|
||||||
@@ -586,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
|
// Group by episode for episodic projects
|
||||||
const isEpisodic = selectedProject?.projectType === "EPISODIC";
|
const isEpisodic = selectedProject?.projectType === "EPISODIC";
|
||||||
const episodeGroups: [string, ShotRow[]][] = isEpisodic
|
const episodeGroups: [string, ShotRow[]][] = isEpisodic
|
||||||
@@ -722,15 +1024,36 @@ export function ShotStatusClient({
|
|||||||
<span className="text-sm font-medium text-blue-300">
|
<span className="text-sm font-medium text-blue-300">
|
||||||
{selectionCount} shot{selectionCount !== 1 ? "s" : ""} selected
|
{selectionCount} shot{selectionCount !== 1 ? "s" : ""} selected
|
||||||
</span>
|
</span>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 gap-1.5 border-blue-500/40 text-blue-300 hover:bg-blue-500/20 hover:text-blue-200"
|
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" />
|
Bulk Actions
|
||||||
Change Due Date
|
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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
|
<button
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="text-zinc-500 hover:text-zinc-300 transition-colors"
|
className="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
@@ -925,6 +1248,25 @@ export function ShotStatusClient({
|
|||||||
onApply={handleBulkDueDate}
|
onApply={handleBulkDueDate}
|
||||||
count={selectionCount}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,14 @@ async function getShotsForProject(projectId: string) {
|
|||||||
priority: true,
|
priority: true,
|
||||||
dueDate: true,
|
dueDate: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
|
generatedThumbnailUrl: true,
|
||||||
notes: true,
|
notes: true,
|
||||||
description: true,
|
description: true,
|
||||||
isKeyShot: 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 } },
|
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||||
shotGroup: { select: { id: true, name: true } },
|
shotGroup: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
|
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
|
export const maxDuration = 300; // 5 min — large .mov high-res files
|
||||||
|
|
||||||
@@ -194,8 +194,14 @@ export async function POST(
|
|||||||
data: { status: "INTERNAL_REVIEW" },
|
data: { status: "INTERNAL_REVIEW" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recalculate the parent shot's status
|
// Recalculate the parent shot's status, clearing any stale sign-off first so
|
||||||
await recalcShotStatus(shotId).catch(() => {});
|
// 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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { ApprovalStatus } from "@prisma/client";
|
import { ApprovalStatus } from "@prisma/client";
|
||||||
import { recalcShotStatus } from "@/lib/shot-status";
|
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
|
||||||
import { validateReviewToken } from "@/lib/review-auth";
|
import { validateReviewToken, getOrCreateReviewUser } 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
@@ -48,9 +35,12 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "APPROVE") {
|
if (action === "APPROVE") {
|
||||||
await db.shot.update({
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.shot.update({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
data: { shotApprovalStatus: "CLIENT_APPROVED" },
|
||||||
|
});
|
||||||
|
await recalcShotStatus(shotId, tx);
|
||||||
});
|
});
|
||||||
} else if (action === "NEEDS_CHANGES") {
|
} else if (action === "NEEDS_CHANGES") {
|
||||||
// Increment shotVersion: v001 → v002, v009 → v010, etc.
|
// 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 nextNum = verMatch ? parseInt(verMatch[1], 10) + 1 : 2;
|
||||||
const nextVersion = "v" + String(nextNum).padStart(3, "0");
|
const nextVersion = "v" + String(nextNum).padStart(3, "0");
|
||||||
|
|
||||||
|
const user = await getOrCreateReviewUser(session, token);
|
||||||
|
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
await tx.shot.update({
|
await tx.shot.update({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
data: { shotApprovalStatus: "PENDING", sharedWithClient: false, shotVersion: nextVersion },
|
data: { shotVersion: nextVersion },
|
||||||
});
|
});
|
||||||
// Mark all non-DONE tasks as CHANGES
|
|
||||||
await tx.task.updateMany({
|
// Clears the approval, unshares, marks the tasks needing rework and
|
||||||
where: { shotId, status: { not: "DONE" } },
|
// recalculates — including the case where every task was already DONE,
|
||||||
data: { status: "CHANGES" },
|
// 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 {
|
} else {
|
||||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
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 });
|
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
const user = await getOrCreateReviewUser(session, token);
|
||||||
const user = await getOrCreateClientUser(email, session.label);
|
|
||||||
|
|
||||||
// Record approval
|
// Record approval
|
||||||
await db.approval.create({
|
await db.approval.create({
|
||||||
@@ -126,9 +139,12 @@ export async function POST(
|
|||||||
select: { sharedWithClient: true, shotApprovalStatus: true },
|
select: { sharedWithClient: true, shotApprovalStatus: true },
|
||||||
});
|
});
|
||||||
if (shot?.sharedWithClient && shot.shotApprovalStatus !== "CLIENT_APPROVED") {
|
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({
|
await db.shot.update({
|
||||||
where: { id: version.task.shot.id },
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||||
import { validateReviewToken } from "@/lib/review-auth";
|
import { validateReviewToken, getOrCreateReviewUser } 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
@@ -52,8 +38,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve commenter identity
|
// Resolve commenter identity
|
||||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
const user = await getOrCreateReviewUser(session, token);
|
||||||
const user = await getOrCreateClientUser(email, session.label);
|
|
||||||
|
|
||||||
const comment = await db.comment.create({
|
const comment = await db.comment.create({
|
||||||
data: {
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { validateReviewToken } from "@/lib/review-auth";
|
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 */
|
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -43,12 +45,14 @@ export async function GET(
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
shotCode: true,
|
shotCode: true,
|
||||||
|
clientCode: true,
|
||||||
episode: true,
|
episode: true,
|
||||||
sequence: true,
|
sequence: true,
|
||||||
description: true,
|
description: true,
|
||||||
status: true,
|
status: true,
|
||||||
shotApprovalStatus: true,
|
shotApprovalStatus: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
|
generatedThumbnailUrl: true,
|
||||||
tasks: {
|
tasks: {
|
||||||
where: {
|
where: {
|
||||||
versions: { some: { isClientVisible: true } },
|
versions: { some: { isClientVisible: true } },
|
||||||
@@ -68,7 +72,6 @@ export async function GET(
|
|||||||
fps: true,
|
fps: true,
|
||||||
duration: true,
|
duration: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
notes: true,
|
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -100,7 +103,6 @@ export async function GET(
|
|||||||
fps: true,
|
fps: true,
|
||||||
duration: true,
|
duration: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
notes: true,
|
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -115,7 +117,22 @@ export async function GET(
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
project,
|
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,
|
assetTasks,
|
||||||
sessionLabel: session.label,
|
sessionLabel: session.label,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
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 */
|
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -40,7 +41,7 @@ export async function GET(
|
|||||||
task: {
|
task: {
|
||||||
include: {
|
include: {
|
||||||
project: { select: { id: true, name: true, code: true } },
|
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 } },
|
asset: { select: { assetCode: true, name: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -64,7 +65,7 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const comments = await db.comment.findMany({
|
const commentRows = await db.comment.findMany({
|
||||||
where: { versionId },
|
where: { versionId },
|
||||||
orderBy: { frameNumber: "asc" },
|
orderBy: { frameNumber: "asc" },
|
||||||
include: {
|
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)
|
// 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;
|
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 = {
|
const serializedVersion = {
|
||||||
...version,
|
...version,
|
||||||
fileSize: version.fileSize?.toString() ?? null,
|
fileSize: version.fileSize?.toString() ?? null,
|
||||||
@@ -92,17 +106,24 @@ export async function GET(
|
|||||||
shot: version.shot
|
shot: version.shot
|
||||||
? {
|
? {
|
||||||
...version.shot,
|
...version.shot,
|
||||||
|
shotCode: clientShotCode(version.shot),
|
||||||
|
clientCode: undefined,
|
||||||
hasHighRes: !!version.shot.highResKey,
|
hasHighRes: !!version.shot.highResKey,
|
||||||
highResFilename: version.shot.highResFilename ?? null,
|
highResFilename: version.shot.highResFilename ?? null,
|
||||||
highResKey: undefined,
|
highResKey: undefined,
|
||||||
}
|
}
|
||||||
: null,
|
: 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
|
task: version.task
|
||||||
? {
|
? {
|
||||||
...version.task,
|
...version.task,
|
||||||
|
title: namingShot ? clientTaskTitle(version.task.title, namingShot) : version.task.title,
|
||||||
shot: (version.task as any)?.shot
|
shot: (version.task as any)?.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
|
highResKey: undefined, // strip storage key
|
||||||
}
|
}
|
||||||
: null,
|
: 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { createExport } from "@/lib/render-pipeline/exports";
|
|||||||
|
|
||||||
// ── POST /api/ext/exports (E1 — Queue Export) ────────────────────────────────
|
// ── POST /api/ext/exports (E1 — Queue Export) ────────────────────────────────
|
||||||
//
|
//
|
||||||
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force? }
|
// Body: { manifest: RenderManifest, submittedByEmail?, priority?, force?, skipPreview? }
|
||||||
// The server decides the new version number, updates Shot.shotVersion/exrOutput,
|
// The server decides the new version number, updates Shot.shotVersion/exrOutput,
|
||||||
// supersedes older non-terminal exports, and creates Export(QUEUED) + RenderJob.
|
// supersedes older non-terminal exports, and creates Export(QUEUED) + RenderJob.
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export async function POST(req: NextRequest) {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
vfxScope?: string | null;
|
vfxScope?: string | null;
|
||||||
submissionNote?: string | null;
|
submissionNote?: string | null;
|
||||||
|
skipPreview?: boolean;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
@@ -38,6 +39,7 @@ export async function POST(req: NextRequest) {
|
|||||||
// undefined inherits the shot's previous export values
|
// undefined inherits the shot's previous export values
|
||||||
vfxScope: body.vfxScope,
|
vfxScope: body.vfxScope,
|
||||||
submissionNote: body.submissionNote,
|
submissionNote: body.submissionNote,
|
||||||
|
skipPreview: body.skipPreview === true,
|
||||||
});
|
});
|
||||||
return NextResponse.json(result, { status: 201 });
|
return NextResponse.json(result, { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ function isAuthorized(req: NextRequest): boolean {
|
|||||||
//
|
//
|
||||||
// Query params (all optional):
|
// Query params (all optional):
|
||||||
// episode e.g. 108
|
// episode e.g. 108
|
||||||
|
// scene e.g. 022
|
||||||
// sequence e.g. 004
|
// sequence e.g. 004
|
||||||
// status e.g. IN_PROGRESS
|
// status e.g. IN_PROGRESS
|
||||||
// page (default 1)
|
// page (default 1)
|
||||||
@@ -39,6 +41,7 @@ export async function GET(
|
|||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
|
|
||||||
const episode = searchParams.get("episode")?.trim() || undefined;
|
const episode = searchParams.get("episode")?.trim() || undefined;
|
||||||
|
const scene = searchParams.get("scene")?.trim() || undefined;
|
||||||
const sequence = searchParams.get("sequence")?.trim() || undefined;
|
const sequence = searchParams.get("sequence")?.trim() || undefined;
|
||||||
const status = searchParams.get("status")?.trim() || undefined;
|
const status = searchParams.get("status")?.trim() || undefined;
|
||||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
||||||
@@ -59,6 +62,7 @@ export async function GET(
|
|||||||
where: {
|
where: {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
...(episode ? { episode } : {}),
|
...(episode ? { episode } : {}),
|
||||||
|
...(scene ? { scene } : {}),
|
||||||
...(sequence ? { sequence } : {}),
|
...(sequence ? { sequence } : {}),
|
||||||
...(status ? { status: status as never } : {}),
|
...(status ? { status: status as never } : {}),
|
||||||
},
|
},
|
||||||
@@ -88,6 +92,7 @@ export async function GET(
|
|||||||
seqTimecodeStart: true,
|
seqTimecodeStart: true,
|
||||||
seqTimecodeEnd: true,
|
seqTimecodeEnd: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
|
generatedThumbnailUrl: true,
|
||||||
artist: {
|
artist: {
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
},
|
},
|
||||||
@@ -98,6 +103,7 @@ export async function GET(
|
|||||||
where: {
|
where: {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
...(episode ? { episode } : {}),
|
...(episode ? { episode } : {}),
|
||||||
|
...(scene ? { scene } : {}),
|
||||||
...(sequence ? { sequence } : {}),
|
...(sequence ? { sequence } : {}),
|
||||||
...(status ? { status: status as never } : {}),
|
...(status ? { status: status as never } : {}),
|
||||||
},
|
},
|
||||||
@@ -107,6 +113,9 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
project,
|
project,
|
||||||
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { resolveProjectTemplates } from "@/lib/render-pipeline/project-templates";
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,11 +51,22 @@ export async function GET(req: NextRequest) {
|
|||||||
status: true,
|
status: true,
|
||||||
startDate: true,
|
startDate: true,
|
||||||
dueDate: true,
|
dueDate: true,
|
||||||
|
pipelineConfig: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { shots: true },
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import { completeRender } from "@/lib/render-pipeline/jobs";
|
|||||||
|
|
||||||
// ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ──────────────────
|
// ── POST /api/ext/render/jobs/{jobId}/complete-render (E11) ──────────────────
|
||||||
//
|
//
|
||||||
// aerender finished with exit 0. Phase 2 interim: Export goes to a provisional
|
// aerender finished with exit 0 → Export goes to VALIDATING and the worker
|
||||||
// READY_FOR_QC; Phase 3 inserts VALIDATING between (§17.3).
|
// 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(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|||||||
@@ -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 { reportValidation, validationReportSchema } from "@/lib/render-pipeline/validation";
|
||||||
|
|
||||||
|
// ── POST /api/ext/render/jobs/{jobId}/validation (E12) ───────────────────────
|
||||||
|
//
|
||||||
|
// The worker reports every check from one validation run in a single call.
|
||||||
|
// Any FAIL stops the pipeline (Export → VALIDATION_FAILED); otherwise the
|
||||||
|
// preview stage is queued.
|
||||||
|
|
||||||
|
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 = validationReportSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validation report is malformed", details: parsed.error.issues },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await reportValidation(jobId, parsed.data);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const { body, status } = toErrorResponse(err);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export async function GET(req: NextRequest) {
|
|||||||
seqTimecodeEnd: true,
|
seqTimecodeEnd: true,
|
||||||
shotVersion: true,
|
shotVersion: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
|
generatedThumbnailUrl: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
project: {
|
project: {
|
||||||
@@ -104,5 +106,8 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ shot });
|
const { generatedThumbnailUrl, ...rest } = shot;
|
||||||
|
return NextResponse.json({
|
||||||
|
shot: { ...rest, thumbnailUrl: shotThumbnail(shot) },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||||
|
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||||
|
import { recordToolVersion, toolPingSchema } from "@/lib/render-pipeline/tools";
|
||||||
|
|
||||||
|
// ── POST /api/ext/tools/ping ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A studio tool reporting its own version on startup. Fire-and-forget by
|
||||||
|
// design: callers must not fail if this errors.
|
||||||
|
|
||||||
|
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 = toolPingSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid tool ping", details: parsed.error.issues }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await recordToolVersion(parsed.data);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const { body, status } = toErrorResponse(err);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -33,6 +34,7 @@ export async function GET(req: NextRequest) {
|
|||||||
shotApprovalStatus: true,
|
shotApprovalStatus: true,
|
||||||
sharedWithClient: true,
|
sharedWithClient: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
|
generatedThumbnailUrl: true,
|
||||||
tasks: {
|
tasks: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -75,7 +77,7 @@ export async function GET(req: NextRequest) {
|
|||||||
status: shot.status,
|
status: shot.status,
|
||||||
shotApprovalStatus: shot.shotApprovalStatus,
|
shotApprovalStatus: shot.shotApprovalStatus,
|
||||||
sharedWithClient: shot.sharedWithClient,
|
sharedWithClient: shot.sharedWithClient,
|
||||||
thumbnailUrl: shot.thumbnailUrl,
|
thumbnailUrl: shotThumbnail(shot),
|
||||||
latestVersion: {
|
latestVersion: {
|
||||||
id: latest.id,
|
id: latest.id,
|
||||||
versionNumber: latest.versionNumber,
|
versionNumber: latest.versionNumber,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||||
|
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||||
|
import { cancelBlenderJob, retryBlenderJob } from "@/lib/render-pipeline/blender";
|
||||||
|
|
||||||
|
// ── POST /api/render/blender/{jobId}?action=cancel|retry ─────────────────────
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ jobId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await requirePipelineUser();
|
||||||
|
const { jobId } = await params;
|
||||||
|
const action = new URL(req.url).searchParams.get("action");
|
||||||
|
|
||||||
|
if (action === "retry") {
|
||||||
|
return NextResponse.json(await retryBlenderJob(jobId));
|
||||||
|
}
|
||||||
|
if (action === "cancel") {
|
||||||
|
return NextResponse.json(await cancelBlenderJob(jobId, user.id));
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "action must be cancel or retry" }, { status: 400 });
|
||||||
|
} catch (err) {
|
||||||
|
const { body, status } = toErrorResponse(err);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { RenderJobStatus } from "@prisma/client";
|
||||||
|
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||||
|
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||||
|
import { listBlenderJobs } from "@/lib/render-pipeline/blender";
|
||||||
|
|
||||||
|
// ── GET /api/render/blender?projectId=&status= ───────────────────────────────
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
await requirePipelineUser();
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const status = searchParams.get("status");
|
||||||
|
const result = await listBlenderJobs({
|
||||||
|
projectId: searchParams.get("projectId") ?? undefined,
|
||||||
|
status: status ? (status as RenderJobStatus) : undefined,
|
||||||
|
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,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||||
|
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||||
|
import { overrideValidation, overrideValidationSchema } from "@/lib/render-pipeline/validation";
|
||||||
|
|
||||||
|
// ── POST /api/render/exports/{exportId}/override-validation ──────────────────
|
||||||
|
//
|
||||||
|
// A human overruling a failed validation run. Resumes the pipeline at the
|
||||||
|
// preview stage, so the export still goes through QC; the failed checks stay on
|
||||||
|
// record and keep showing up as warnings in the QC queue.
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ exportId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Waving a failed check through is a supervisor's call, not any user's.
|
||||||
|
const user = await requirePipelineAdmin();
|
||||||
|
const { exportId } = await params;
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = overrideValidationSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "A reason is required", details: parsed.error.issues }, { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await overrideValidation(exportId, {
|
||||||
|
reason: parsed.data.reason,
|
||||||
|
skipPreview: parsed.data.skipPreview,
|
||||||
|
userId: user.id,
|
||||||
|
userName: user.name ?? user.email ?? null,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const { body, status } = toErrorResponse(err);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||||
|
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||||
|
import { qcSubmitSchema, submitQCReview } from "@/lib/render-pipeline/qc";
|
||||||
|
|
||||||
|
// ── POST /api/render/exports/{exportId}/qc ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// Web twin of E4 — same service, session auth. QC is not locked to AE (§10.2).
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ exportId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await requirePipelineUser();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await submitQCReview(exportId, parsed.data, {
|
||||||
|
source: "WEB",
|
||||||
|
userId: user.id,
|
||||||
|
name: user.name ?? user.email ?? null,
|
||||||
|
});
|
||||||
|
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 { getQCQueue } from "@/lib/render-pipeline/qc";
|
||||||
|
|
||||||
|
// ── GET /api/render/qc/queue?projectId=&episode= ─────────────────────────────
|
||||||
|
//
|
||||||
|
// Web twin of E5 for the QC page.
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
await requirePipelineUser();
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const result = await getQCQueue({
|
||||||
|
projectId: searchParams.get("projectId"),
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
function requireRole(role: string) {
|
function requireRole(role: string) {
|
||||||
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
|
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
|
||||||
@@ -27,6 +28,7 @@ export async function GET(
|
|||||||
scene: true,
|
scene: true,
|
||||||
episode: true,
|
episode: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
|
generatedThumbnailUrl: true,
|
||||||
references: {
|
references: {
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
select: {
|
select: {
|
||||||
@@ -41,5 +43,9 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!shot) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!shot) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
return NextResponse.json({ shot });
|
|
||||||
|
const { generatedThumbnailUrl, ...rest } = shot;
|
||||||
|
return NextResponse.json({
|
||||||
|
shot: { ...rest, thumbnailUrl: shotThumbnail(shot) },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -409,13 +410,18 @@ export async function GET(req: NextRequest) {
|
|||||||
where: { projectId },
|
where: { projectId },
|
||||||
select: {
|
select: {
|
||||||
id: true, shotCode: true, scene: true, episode: true, shotNumber: true,
|
id: true, shotCode: true, scene: true, episode: true, shotNumber: true,
|
||||||
description: true, notes: true, status: true, thumbnailUrl: true,
|
description: true, notes: true, status: true,
|
||||||
|
thumbnailUrl: true, generatedThumbnailUrl: true,
|
||||||
frameStart: true, frameEnd: true, fps: true, isKeyShot: true, shotVersion: true,
|
frameStart: true, frameEnd: true, fps: true, isKeyShot: true, shotVersion: true,
|
||||||
shotGroup: { select: { id: true, name: true } },
|
shotGroup: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ episode: "asc" }, { scene: "asc" }, { shotNumber: "asc" }],
|
orderBy: [{ episode: "asc" }, { scene: "asc" }, { shotNumber: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Collapse the two columns before anything reads them, so the "only shots
|
||||||
|
// with thumbnails" filter and the page renderer agree on what counts as one.
|
||||||
|
shots = shots.map((s) => ({ ...s, thumbnailUrl: shotThumbnail(s) }));
|
||||||
|
|
||||||
if (onlyThumbs) shots = shots.filter((s) => !!s.thumbnailUrl);
|
if (onlyThumbs) shots = shots.filter((s) => !!s.thumbnailUrl);
|
||||||
if (epFilter.length) shots = shots.filter((s) => epFilter.includes(s.episode ?? ""));
|
if (epFilter.length) shots = shots.filter((s) => epFilter.includes(s.episode ?? ""));
|
||||||
if (grpFilter.length) shots = shots.filter((s) => grpFilter.includes(s.shotGroup?.id ?? ""));
|
if (grpFilter.length) shots = shots.filter((s) => grpFilter.includes(s.shotGroup?.id ?? ""));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
// GET /api/storyboard/shots?projectId=xxx
|
// GET /api/storyboard/shots?projectId=xxx
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -26,6 +27,7 @@ export async function GET(req: NextRequest) {
|
|||||||
notes: true,
|
notes: true,
|
||||||
status: true,
|
status: true,
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
|
generatedThumbnailUrl: true,
|
||||||
frameStart: true,
|
frameStart: true,
|
||||||
frameEnd: true,
|
frameEnd: true,
|
||||||
fps: true,
|
fps: true,
|
||||||
@@ -40,5 +42,12 @@ export async function GET(req: NextRequest) {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ shots });
|
// The reference still wins; a render-derived one fills the gap where a show
|
||||||
|
// shipped no stills at all (lib/shot-thumbnail.ts).
|
||||||
|
return NextResponse.json({
|
||||||
|
shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({
|
||||||
|
...shot,
|
||||||
|
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||||
|
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||||
|
import { listToolVersions } from "@/lib/render-pipeline/tools";
|
||||||
|
|
||||||
|
// ── GET /api/tools ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// What version of each studio tool is deployed where, with anything behind the
|
||||||
|
// newest seen version flagged.
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requirePipelineUser();
|
||||||
|
const result = await listToolVersions();
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const { body, status } = toErrorResponse(err);
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@ interface ClientVersion {
|
|||||||
fps: number;
|
fps: number;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
notes: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,11 +743,6 @@ export default function ClientPortalPage({
|
|||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||||
</p>
|
</p>
|
||||||
{ver?.notes && (
|
|
||||||
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
|
||||||
“{ver.notes}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{ver ? (
|
{ver ? (
|
||||||
<span
|
<span
|
||||||
@@ -919,11 +913,6 @@ export default function ClientPortalPage({
|
|||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||||
</p>
|
</p>
|
||||||
{ver?.notes && (
|
|
||||||
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
|
||||||
“{ver.notes}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{ver ? (
|
{ver ? (
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
@@ -1028,11 +1017,6 @@ export default function ClientPortalPage({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{ver?.notes && (
|
|
||||||
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
|
|
||||||
“{ver.notes}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{ver ? (
|
{ver ? (
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Download,
|
Download,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useReviewStore } from "@/hooks/use-review-player";
|
import { useReviewStore } from "@/hooks/use-review-player";
|
||||||
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
|
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
|
||||||
@@ -42,6 +44,8 @@ interface Comment {
|
|||||||
text: string;
|
text: string;
|
||||||
isResolved: boolean;
|
isResolved: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/** This reviewer wrote it, so they can change or remove it. Set by the API. */
|
||||||
|
canEdit?: boolean;
|
||||||
author: { id: string; name: string | null; image: string | null; email: string };
|
author: { id: string; name: string | null; image: string | null; email: string };
|
||||||
replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[];
|
replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[];
|
||||||
}
|
}
|
||||||
@@ -104,6 +108,13 @@ export default function ClientReviewPage({
|
|||||||
const [commentFrame, setCommentFrame] = useState<number | null>(null);
|
const [commentFrame, setCommentFrame] = useState<number | null>(null);
|
||||||
const [submittingComment, setSubmittingComment] = useState(false);
|
const [submittingComment, setSubmittingComment] = useState(false);
|
||||||
|
|
||||||
|
// Editing / deleting one's own notes
|
||||||
|
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||||
|
const [editingText, setEditingText] = useState("");
|
||||||
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
const [deletingComment, setDeletingComment] = useState<Comment | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// Approval dialog
|
// Approval dialog
|
||||||
const [approvalDialog, setApprovalDialog] = useState<{
|
const [approvalDialog, setApprovalDialog] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -209,6 +220,58 @@ export default function ClientReviewPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const beginEditComment = (comment: Comment) => {
|
||||||
|
setEditingCommentId(comment.id);
|
||||||
|
setEditingText(comment.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditComment = () => {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setEditingText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEditedComment = async () => {
|
||||||
|
const trimmed = editingText.trim();
|
||||||
|
if (!editingCommentId || !trimmed || !version) return;
|
||||||
|
setSavingEdit(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/client/${token}/comment/${editingCommentId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text: trimmed }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to save note");
|
||||||
|
cancelEditComment();
|
||||||
|
await refreshComments(token, version.id);
|
||||||
|
toast({ title: "Note updated" });
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to update note", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setSavingEdit(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = async () => {
|
||||||
|
if (!deletingComment || !version) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/client/${token}/comment/${deletingComment.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete note");
|
||||||
|
setDeletingComment(null);
|
||||||
|
// Deleting a drawing note also removes its strokes server-side, and those
|
||||||
|
// live in the canvas's own state — a full reload remounts the player so
|
||||||
|
// the frame does not keep showing marks that no longer exist.
|
||||||
|
loadVersion(token, version.id);
|
||||||
|
toast({ title: "Note deleted" });
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to delete note", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownloadHighRes = async () => {
|
const handleDownloadHighRes = async () => {
|
||||||
const shotId = version?.highResShotId ?? version?.shot?.id;
|
const shotId = version?.highResShotId ?? version?.shot?.id;
|
||||||
if (!shotId || !token) return;
|
if (!shotId || !token) return;
|
||||||
@@ -510,8 +573,51 @@ export default function ClientReviewPage({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<div key={comment.id} className="space-y-2">
|
<div key={comment.id} className="space-y-2">
|
||||||
|
{editingCommentId === comment.id ? (
|
||||||
|
// Editing replaces the note in place, so it stays in the
|
||||||
|
// list at its own frame rather than jumping to a dialog.
|
||||||
|
<div className="rounded-lg bg-zinc-800/70 p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-zinc-400">Editing note</span>
|
||||||
|
<span className="ml-auto font-mono text-xs text-amber-400/80">
|
||||||
|
{frameToTimecode(comment.frameNumber, version.fps)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={editingText}
|
||||||
|
onChange={(e) => setEditingText(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={5000}
|
||||||
|
autoFocus
|
||||||
|
className="bg-zinc-900 border-zinc-700 text-sm resize-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSaveEditedComment();
|
||||||
|
if (e.key === "Escape") cancelEditComment();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!editingText.trim() || savingEdit}
|
||||||
|
onClick={handleSaveEditedComment}
|
||||||
|
>
|
||||||
|
{savingEdit ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={cancelEditComment}
|
||||||
|
disabled={savingEdit}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
|
className="group rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
|
||||||
onClick={() => playerRef.current?.seekToFrame(comment.frameNumber)}
|
onClick={() => playerRef.current?.seekToFrame(comment.frameNumber)}
|
||||||
title={`Jump to frame ${comment.frameNumber}`}
|
title={`Jump to frame ${comment.frameNumber}`}
|
||||||
>
|
>
|
||||||
@@ -529,7 +635,36 @@ export default function ClientReviewPage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
|
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
|
||||||
|
{comment.canEdit && (
|
||||||
|
// Own notes only. The buttons stop propagation so they
|
||||||
|
// do not also seek the player to the note's frame.
|
||||||
|
<div className="flex items-center gap-1 pt-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-zinc-400 hover:text-white hover:bg-zinc-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
beginEditComment(comment);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-zinc-400 hover:text-red-400 hover:bg-zinc-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeletingComment(comment);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{comment.replies.map((reply) => (
|
{comment.replies.map((reply) => (
|
||||||
<div key={reply.id} className="ml-4 rounded-lg bg-zinc-800/40 border border-zinc-700/50 p-2.5">
|
<div key={reply.id} className="ml-4 rounded-lg bg-zinc-800/40 border border-zinc-700/50 p-2.5">
|
||||||
<span className="text-xs text-zinc-400 font-medium">
|
<span className="text-xs text-zinc-400 font-medium">
|
||||||
@@ -658,6 +793,49 @@ export default function ClientReviewPage({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete confirmation — a note may already have been actioned by the
|
||||||
|
studio, so removing it is worth one deliberate step. */}
|
||||||
|
<Dialog
|
||||||
|
open={!!deletingComment}
|
||||||
|
onOpenChange={(open) => { if (!open) setDeletingComment(null); }}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this note?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{deletingComment && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-zinc-500 font-mono">
|
||||||
|
Frame {deletingComment.frameNumber} ·{" "}
|
||||||
|
{version ? frameToTimecode(deletingComment.frameNumber, version.fps) : ""}
|
||||||
|
</p>
|
||||||
|
<p className="rounded bg-zinc-800/70 p-3 text-sm text-zinc-200">
|
||||||
|
{deletingComment.text}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
This removes it for everyone, including the studio. It cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDeletingComment(null)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteComment}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete note"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { updateProject } from "@/actions/projects";
|
import { updateProject } from "@/actions/projects";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { Settings, Building2, Users, Webhook } from "lucide-react";
|
import { Settings, Building2, Users, Webhook, Clapperboard } from "lucide-react";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
name: z.string().min(1, "Name is required").max(100),
|
name: z.string().min(1, "Name is required").max(100),
|
||||||
@@ -35,6 +35,13 @@ const settingsSchema = z.object({
|
|||||||
startDate: z.string().optional(),
|
startDate: z.string().optional(),
|
||||||
slackWebhook: z.string().optional(),
|
slackWebhook: z.string().optional(),
|
||||||
slackChannel: z.string().optional(),
|
slackChannel: z.string().optional(),
|
||||||
|
// Pipeline — see the Pipeline section for why only paths are required here.
|
||||||
|
templateAep: z.string().optional(),
|
||||||
|
rendersRoot: z.string().optional(),
|
||||||
|
overlayComp: z.string().optional(),
|
||||||
|
previewComp: z.string().optional(),
|
||||||
|
lutComp: z.string().optional(),
|
||||||
|
autoShotThumbnail: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||||||
@@ -67,6 +74,14 @@ interface ProjectSettingsTabProps {
|
|||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
slackWebhook: string | null;
|
slackWebhook: string | null;
|
||||||
slackChannel: 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[];
|
clients: Client[];
|
||||||
teamMembers: TeamMember[];
|
teamMembers: TeamMember[];
|
||||||
@@ -109,9 +124,20 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet
|
|||||||
startDate: toDateInput(project.startDate),
|
startDate: toDateInput(project.startDate),
|
||||||
slackWebhook: project.slackWebhook ?? "",
|
slackWebhook: project.slackWebhook ?? "",
|
||||||
slackChannel: project.slackChannel ?? "",
|
slackChannel: project.slackChannel ?? "",
|
||||||
|
templateAep: project.pipelineConfig?.templateAep ?? "",
|
||||||
|
rendersRoot: project.pipelineConfig?.rendersRoot ?? "",
|
||||||
|
overlayComp: project.pipelineConfig?.overlayComp ?? "",
|
||||||
|
previewComp: project.pipelineConfig?.previewComp ?? "",
|
||||||
|
lutComp: project.pipelineConfig?.lutComp ?? "",
|
||||||
|
autoShotThumbnail: project.pipelineConfig?.autoShotThumbnail ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Blank comp-name fields mean "use the convention", so they are shown as
|
||||||
|
// placeholders rather than pre-filled: a project that follows the pattern
|
||||||
|
// should have nothing stored, and inherit any future change to it.
|
||||||
|
const showId = watch("showId") || project.showId;
|
||||||
|
|
||||||
const onSubmit = async (data: SettingsFormValues) => {
|
const onSubmit = async (data: SettingsFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -130,6 +156,14 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet
|
|||||||
startDate: data.startDate || null,
|
startDate: data.startDate || null,
|
||||||
slackWebhook: data.slackWebhook || null,
|
slackWebhook: data.slackWebhook || null,
|
||||||
slackChannel: data.slackChannel || null,
|
slackChannel: data.slackChannel || null,
|
||||||
|
pipelineConfig: {
|
||||||
|
templateAep: data.templateAep?.trim() || null,
|
||||||
|
rendersRoot: data.rendersRoot?.trim() || null,
|
||||||
|
overlayComp: data.overlayComp?.trim() || null,
|
||||||
|
previewComp: data.previewComp?.trim() || null,
|
||||||
|
lutComp: data.lutComp?.trim() || null,
|
||||||
|
autoShotThumbnail: data.autoShotThumbnail ?? false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
toast({ title: "Project settings saved" });
|
toast({ title: "Project settings saved" });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -338,6 +372,90 @@ export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSet
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Pipeline ── */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clapperboard className="h-4 w-4 text-zinc-400" />
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-200">Pipeline</h2>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Where this show's After Effects template lives. Used by the connector panel and by the
|
||||||
|
render farm's preview build alike, so both work from the same template — and, above
|
||||||
|
all, the same show LUT.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label htmlFor="templateAep">Template project (.aep)</Label>
|
||||||
|
<Input
|
||||||
|
id="templateAep"
|
||||||
|
placeholder="X:/shared_projects_2026/UNGO_VFX/production/working_files/UNG_VFXOVERLAY_SLATE_TEMPLATE.aep"
|
||||||
|
{...register("templateAep")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Holds the overlay, the slate/export template and the show LUT. Imported by
|
||||||
|
Initialise Workspace and by every headless preview build.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label htmlFor="rendersRoot">Renders root</Label>
|
||||||
|
<Input
|
||||||
|
id="rendersRoot"
|
||||||
|
placeholder="X:/shared_projects_2026/UNGO_VFX/production/renders"
|
||||||
|
{...register("rendersRoot")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Where per-shot pre-renders live; the panel appends the shot code. Used by Import Renders.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
Comp names default to the convention below and only need filling in when this show's
|
||||||
|
template departs from it. Leave blank to follow the convention.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4 pt-1">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="overlayComp">Overlay comp</Label>
|
||||||
|
<Input id="overlayComp" placeholder={`${showId}_VFX_OVERLAY`} {...register("overlayComp")} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="previewComp">Slate / export comp</Label>
|
||||||
|
<Input id="previewComp" placeholder={`${showId}_EXPORT_TEMPLATE`} {...register("previewComp")} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="lutComp">Show LUT comp</Label>
|
||||||
|
<Input id="lutComp" placeholder="_SHOW LUT" {...register("lutComp")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 mt-4 border-t border-border/60">
|
||||||
|
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
id="autoShotThumbnail"
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-amber-500 h-3.5 w-3.5 mt-0.5"
|
||||||
|
{...register("autoShotThumbnail")}
|
||||||
|
/>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm group-hover:text-foreground transition-colors">
|
||||||
|
Use render frames as shot thumbnails
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
Every finished export sets its shot's thumbnail from the render.
|
||||||
|
A thumbnail supplied with the brief always takes priority and is
|
||||||
|
never overwritten — turn this on for shows that arrive with no
|
||||||
|
stills at all, where the alternative is an empty grid.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Save */}
|
{/* Save */}
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
<Button type="submit" disabled={isSubmitting || !isDirty} className="min-w-[120px]">
|
<Button type="submit" disabled={isSubmitting || !isDirty} className="min-w-[120px]">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ActiveDragData,
|
ActiveDragData,
|
||||||
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||||
import { TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
import { TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
const STATUS_STYLES: Record<
|
const STATUS_STYLES: Record<
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
@@ -134,7 +135,7 @@ export function ScheduleTaskBlock({
|
|||||||
const statusStyle = STATUS_STYLES[task.status];
|
const statusStyle = STATUS_STYLES[task.status];
|
||||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||||
const contextName = task.shot?.shotCode ?? task.asset?.name ?? task.title;
|
const contextName = task.shot?.shotCode ?? task.asset?.name ?? task.title;
|
||||||
const thumbnail = task.shot?.thumbnailUrl ?? null;
|
const thumbnail = shotThumbnail(task.shot);
|
||||||
|
|
||||||
const isOverdue =
|
const isOverdue =
|
||||||
task.dueDate &&
|
task.dueDate &&
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import type { ShotWithDetails } from "@/types";
|
import type { ShotWithDetails } from "@/types";
|
||||||
import { duplicateShot } from "@/actions/shots";
|
import { duplicateShot } from "@/actions/shots";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
interface ShotCardProps {
|
interface ShotCardProps {
|
||||||
shot: ShotWithDetails;
|
shot: ShotWithDetails;
|
||||||
@@ -128,9 +129,9 @@ export function ShotCard({ shot, projectId, compact = false, canManage = false }
|
|||||||
{/* Thumbnail with cinema scope aspect ratio (2.39:1) */}
|
{/* Thumbnail with cinema scope aspect ratio (2.39:1) */}
|
||||||
<div className="mb-3 -mx-6 -mt-6 overflow-hidden rounded-t-lg">
|
<div className="mb-3 -mx-6 -mt-6 overflow-hidden rounded-t-lg">
|
||||||
<div className="relative w-full aspect-[2.39]">
|
<div className="relative w-full aspect-[2.39]">
|
||||||
{shot.thumbnailUrl || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? (
|
{shotThumbnail(shot) || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={shot.thumbnailUrl || latestVersion?.thumbnailUrl || latestVersion?.posterUrl || ""}
|
src={shotThumbnail(shot) || latestVersion?.thumbnailUrl || latestVersion?.posterUrl || ""}
|
||||||
alt={shot.shotCode}
|
alt={shot.shotCode}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Layers, ExternalLink } from "lucide-react";
|
import { Layers, ExternalLink, ImageIcon, Loader2 } from "lucide-react";
|
||||||
|
import { promoteExportThumbnail } from "@/actions/shots";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
EXPORT_STATUS_STYLES,
|
EXPORT_STATUS_STYLES,
|
||||||
@@ -16,6 +21,7 @@ interface ExportRow {
|
|||||||
statusChangedAt: string;
|
statusChangedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
job: {
|
job: {
|
||||||
attempt: number;
|
attempt: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -29,6 +35,29 @@ interface ExportRow {
|
|||||||
* pipeline history for this shot, linking into the Export detail page.
|
* pipeline history for this shot, linking into the Export detail page.
|
||||||
*/
|
*/
|
||||||
export function ShotExportsTab({ shotId }: { shotId: string }) {
|
export function ShotExportsTab({ shotId }: { shotId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [promotingId, setPromotingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Take this export's frame as the shot's thumbnail. Fills in only where the
|
||||||
|
// shot has no reference frame of its own (§9.1a) — it never overwrites one.
|
||||||
|
const promote = async (exportId: string, versionString: string) => {
|
||||||
|
setPromotingId(exportId);
|
||||||
|
try {
|
||||||
|
await promoteExportThumbnail(shotId, exportId);
|
||||||
|
toast({ title: `Shot thumbnail taken from ${versionString}` });
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Could not use that frame",
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPromotingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["shot-exports", shotId],
|
queryKey: ["shot-exports", shotId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -56,11 +85,18 @@ export function ShotExportsTab({ shotId }: { shotId: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{exports.map((e) => (
|
{exports.map((e) => (
|
||||||
<Link
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
href={`/pipeline/exports/${e.id}`}
|
|
||||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 hover:border-zinc-600 transition-colors"
|
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 hover:border-zinc-600 transition-colors"
|
||||||
>
|
>
|
||||||
|
<Link href={`/pipeline/exports/${e.id}`} className="flex flex-1 min-w-0 items-center gap-4">
|
||||||
|
{e.thumbnailUrl ? (
|
||||||
|
<span className="relative h-8 w-[76px] shrink-0 overflow-hidden rounded border border-border">
|
||||||
|
<Image src={e.thumbnailUrl} alt="" fill className="object-cover" sizes="76px" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="h-8 w-[76px] shrink-0 rounded border border-border bg-zinc-900" />
|
||||||
|
)}
|
||||||
<span className="font-mono text-sm text-white w-14">{e.versionString}</span>
|
<span className="font-mono text-sm text-white w-14">{e.versionString}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -79,6 +115,24 @@ export function ShotExportsTab({ shotId }: { shotId: string }) {
|
|||||||
</span>
|
</span>
|
||||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{e.thumbnailUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => promote(e.id, e.versionString)}
|
||||||
|
disabled={promotingId !== null}
|
||||||
|
title="Use this frame as the shot thumbnail (never replaces one supplied with the brief)"
|
||||||
|
className="shrink-0 flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:border-zinc-600 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{promotingId === e.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Use frame
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { updateShot, deleteShot } from "@/actions/shots";
|
import {
|
||||||
|
updateShot,
|
||||||
|
deleteShot,
|
||||||
|
promoteExportThumbnail,
|
||||||
|
clearGeneratedThumbnail,
|
||||||
|
} from "@/actions/shots";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2 } from "lucide-react";
|
import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
// ─── Shot References ──────────────────────────────────────────────────────────
|
// ─── Shot References ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -191,6 +196,8 @@ interface ShotSettingsTabProps {
|
|||||||
dueDate: Date | string | null;
|
dueDate: Date | string | null;
|
||||||
artistId: string | null;
|
artistId: string | null;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
|
generatedThumbnailUrl?: string | null;
|
||||||
|
generatedThumbnailFrom?: string | null;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
artists: Artist[];
|
artists: Artist[];
|
||||||
@@ -208,6 +215,48 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
|||||||
const [clearThumbnail, setClearThumbnail] = useState(false);
|
const [clearThumbnail, setClearThumbnail] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Render-derived still, managed separately from the reference frame above.
|
||||||
|
const [generatedUrl, setGeneratedUrl] = useState(shot.generatedThumbnailUrl ?? null);
|
||||||
|
const [generatedFrom, setGeneratedFrom] = useState(shot.generatedThumbnailFrom ?? null);
|
||||||
|
const [isPromoting, setIsPromoting] = useState(false);
|
||||||
|
|
||||||
|
const handlePromote = async () => {
|
||||||
|
setIsPromoting(true);
|
||||||
|
try {
|
||||||
|
const res = await promoteExportThumbnail(shot.id);
|
||||||
|
setGeneratedUrl(res.generatedThumbnailUrl);
|
||||||
|
setGeneratedFrom(res.generatedThumbnailFrom);
|
||||||
|
toast({ title: `Thumbnail taken from ${res.generatedThumbnailFrom}` });
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Could not use a render frame",
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsPromoting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearGenerated = async () => {
|
||||||
|
setIsPromoting(true);
|
||||||
|
try {
|
||||||
|
await clearGeneratedThumbnail(shot.id);
|
||||||
|
setGeneratedUrl(null);
|
||||||
|
setGeneratedFrom(null);
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Could not clear the render frame",
|
||||||
|
description: (err as Error).message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsPromoting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (d: Date | string | null) => {
|
const formatDate = (d: Date | string | null) => {
|
||||||
if (!d) return "";
|
if (!d) return "";
|
||||||
return new Date(d).toISOString().split("T")[0];
|
return new Date(d).toISOString().split("T")[0];
|
||||||
@@ -452,6 +501,57 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleThumbnailChange}
|
onChange={handleThumbnailChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── From renders ───────────────────────────────────────────────────
|
||||||
|
A second, separate still taken from a finished export. It never
|
||||||
|
replaces the one above — it only shows where there is none — so a
|
||||||
|
show that ships reference frames keeps them, and a show that ships
|
||||||
|
none stops rendering empty grids. */}
|
||||||
|
<div className="pt-4 mt-2 border-t border-border/60 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-amber-500/70" />
|
||||||
|
From renders
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generatedUrl ? (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="relative w-40 aspect-[2.39] rounded-md overflow-hidden border border-border shrink-0">
|
||||||
|
<Image src={generatedUrl} alt={shot.shotCode} fill className="object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{generatedFrom ? <>Taken from <span className="font-mono">{generatedFrom}</span>.</> : "Taken from a render."}{" "}
|
||||||
|
{thumbnailPreview && !clearThumbnail
|
||||||
|
? "Not shown anywhere while this shot has a thumbnail of its own."
|
||||||
|
: "Shown wherever this shot appears, until a thumbnail is uploaded above."}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handlePromote} disabled={isPromoting}>
|
||||||
|
{isPromoting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Refresh from latest"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={handleClearGenerated} disabled={isPromoting}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Take a frame from this shot's most recent finished export. Never
|
||||||
|
replaces the thumbnail above — it only fills in where there
|
||||||
|
isn't one.
|
||||||
|
</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handlePromote} disabled={isPromoting}>
|
||||||
|
{isPromoting ? (
|
||||||
|
<><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> Fetching…</>
|
||||||
|
) : (
|
||||||
|
"Use latest render frame"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* References */}
|
{/* References */}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Film, Save } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { updateShotSlate } from "@/actions/shots";
|
||||||
|
import type { ShotWithDetails } from "@/types";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shot: ShotWithDetails;
|
||||||
|
projectName: string;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Left-side slate row: label + editable/static value */
|
||||||
|
function SlateRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0 border-b border-zinc-800/60 last:border-0">
|
||||||
|
<div className="w-36 shrink-0 py-3 px-4 text-right text-[11px] font-semibold text-zinc-500 uppercase tracking-widest self-start pt-3.5">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 py-2.5 px-3 text-sm text-zinc-100">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Right-side metadata row */
|
||||||
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-800/50 last:border-0 py-1.5 px-3">
|
||||||
|
<span className="text-[11px] text-zinc-500">{label}</span>
|
||||||
|
<span className="text-[11px] text-zinc-300 text-right max-w-[55%] truncate">{value || "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShotSlateTab({ shot, projectName, onSaved }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [slateType, setSlateType] = useState(shot.slateType ?? "");
|
||||||
|
const [slateDescription, setSlateDescription] = useState(shot.slateDescription ?? shot.description ?? "");
|
||||||
|
const [slateVfxScope, setSlateVfxScope] = useState(shot.slateVfxScope ?? "");
|
||||||
|
const [slateSubmissionNote, setSlateSubmissionNote] = useState(shot.slateSubmissionNote ?? "");
|
||||||
|
const [clientCode, setClientCode] = useState(shot.clientCode ?? "");
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = `${today.getFullYear()}/${String(today.getMonth() + 1).padStart(2, "0")}/${String(today.getDate()).padStart(2, "0")}`;
|
||||||
|
// Client code, when set, replaces shotCode in what the client actually
|
||||||
|
// sees (slate, overlay burn-in, delivered names) — just their own naming
|
||||||
|
// with our version appended, no "_cmp_TT_" studio jargon mixed in.
|
||||||
|
const versionName = clientCode.trim()
|
||||||
|
? `${clientCode.trim()}_${shot.shotVersion ?? "v001"}`
|
||||||
|
: `${shot.shotCode}_cmp_TT_${shot.shotVersion ?? "v001"}`;
|
||||||
|
const frames =
|
||||||
|
shot.frameStart != null && shot.frameEnd != null
|
||||||
|
? String(shot.frameEnd - shot.frameStart + 1)
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await updateShotSlate(shot.id, {
|
||||||
|
slateType: slateType.trim() || null,
|
||||||
|
slateDescription: slateDescription.trim() || null,
|
||||||
|
slateVfxScope: slateVfxScope.trim() || null,
|
||||||
|
slateSubmissionNote: slateSubmissionNote.trim() || null,
|
||||||
|
clientCode: clientCode.trim() || null,
|
||||||
|
});
|
||||||
|
toast({ title: "Slate saved" });
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: "Failed to save", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Slate document */}
|
||||||
|
<div className="rounded-xl overflow-hidden border border-zinc-800 bg-zinc-950 max-w-4xl">
|
||||||
|
{/* Top accent bar */}
|
||||||
|
<div className="h-1 bg-red-600" />
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
{/* ── Left column: editorial data ─────────────────────────────── */}
|
||||||
|
<div className="flex-1 border-r border-zinc-800">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex border-b border-zinc-800">
|
||||||
|
<div className="flex-1 px-4 py-3 border-r border-zinc-800">
|
||||||
|
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mb-0.5">Show</p>
|
||||||
|
<p className="text-base font-bold text-white tracking-wide">{projectName.toUpperCase()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2">
|
||||||
|
<p className="text-[10px] text-zinc-500 uppercase tracking-widest">Submitting For</p>
|
||||||
|
<span className="ml-1 px-2 py-0.5 rounded bg-red-600/20 border border-red-600/40 text-red-400 text-xs font-bold tracking-widest">REVIEW</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-filled rows */}
|
||||||
|
<SlateRow label="Version Name">
|
||||||
|
<span className="font-mono text-amber-400">{versionName}</span>
|
||||||
|
</SlateRow>
|
||||||
|
<SlateRow label="Date">
|
||||||
|
<span className="text-zinc-300">{dateStr}</span>
|
||||||
|
</SlateRow>
|
||||||
|
|
||||||
|
{/* Editable rows */}
|
||||||
|
<SlateRow label="Client Code">
|
||||||
|
<Input
|
||||||
|
value={clientCode}
|
||||||
|
onChange={(e) => setClientCode(e.target.value)}
|
||||||
|
placeholder={`Leave blank to use ${shot.shotCode}`}
|
||||||
|
className="h-7 bg-zinc-900 border-zinc-700 text-sm font-mono text-zinc-100 placeholder:text-zinc-600 placeholder:font-sans"
|
||||||
|
/>
|
||||||
|
</SlateRow>
|
||||||
|
|
||||||
|
<SlateRow label="Shot Type">
|
||||||
|
<Input
|
||||||
|
value={slateType}
|
||||||
|
onChange={(e) => setSlateType(e.target.value)}
|
||||||
|
placeholder="e.g. 2D COMP"
|
||||||
|
className="h-7 bg-zinc-900 border-zinc-700 text-sm font-medium text-zinc-100 placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
</SlateRow>
|
||||||
|
|
||||||
|
<SlateRow label="Shot Description">
|
||||||
|
<Textarea
|
||||||
|
value={slateDescription}
|
||||||
|
onChange={(e) => setSlateDescription(e.target.value)}
|
||||||
|
placeholder={shot.description ?? "Shot description for slate…"}
|
||||||
|
rows={2}
|
||||||
|
className="bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600 resize-none min-h-[56px]"
|
||||||
|
/>
|
||||||
|
</SlateRow>
|
||||||
|
|
||||||
|
<SlateRow label="VFX Scope">
|
||||||
|
<Textarea
|
||||||
|
value={slateVfxScope}
|
||||||
|
onChange={(e) => setSlateVfxScope(e.target.value)}
|
||||||
|
placeholder="VFX scope of work…"
|
||||||
|
rows={3}
|
||||||
|
className="bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600 resize-none min-h-[72px]"
|
||||||
|
/>
|
||||||
|
</SlateRow>
|
||||||
|
|
||||||
|
<SlateRow label="Submission Note">
|
||||||
|
<Input
|
||||||
|
value={slateSubmissionNote}
|
||||||
|
onChange={(e) => setSlateSubmissionNote(e.target.value)}
|
||||||
|
placeholder="e.g. Done"
|
||||||
|
className="h-7 bg-zinc-900 border-zinc-700 text-sm text-zinc-100 placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
</SlateRow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right column: thumbnail + metadata ──────────────────────── */}
|
||||||
|
<div className="w-56 shrink-0 flex flex-col">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="aspect-video bg-zinc-900 border-b border-zinc-800 overflow-hidden flex items-center justify-center">
|
||||||
|
{shotThumbnail(shot) ? (
|
||||||
|
<Image
|
||||||
|
src={shotThumbnail(shot)!}
|
||||||
|
alt={shot.shotCode}
|
||||||
|
width={224}
|
||||||
|
height={126}
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Film className="h-8 w-8 text-zinc-700" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="flex-1 text-right">
|
||||||
|
<div className="px-3 py-2 border-b border-zinc-800">
|
||||||
|
<p className="text-[10px] text-zinc-500 mb-0.5">Vendor</p>
|
||||||
|
<p className="text-[11px] text-zinc-200 font-medium leading-tight">TWO TALES ANIMATION (PTY) LTD</p>
|
||||||
|
</div>
|
||||||
|
<MetaRow label="Shot Name" value={shot.shotCode} />
|
||||||
|
<MetaRow label="Episode" value={shot.episode ?? "—"} />
|
||||||
|
<MetaRow label="Seq Name" value={shot.sequence ?? "—"} />
|
||||||
|
<MetaRow label="Scene" value={shot.scene} />
|
||||||
|
<MetaRow label="Frames" value={frames} />
|
||||||
|
<MetaRow label="Media Color" value="w/SHOW LUT" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<div className="flex justify-end max-w-4xl">
|
||||||
|
<Button onClick={handleSave} disabled={isPending} className="gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isPending ? "Saving…" : "Save Slate"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,11 @@ interface KanbanTask {
|
|||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
priority: string;
|
priority: string;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
shot?: { shotCode: string; thumbnailUrl?: string | null } | null;
|
shot?: {
|
||||||
|
shotCode: string;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
generatedThumbnailUrl?: string | null;
|
||||||
|
} | null;
|
||||||
asset?: { assetCode: string; name: string } | null;
|
asset?: { assetCode: string; name: string } | null;
|
||||||
assignedArtist?: {
|
assignedArtist?: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { TaskStatus, TaskType } from "@prisma/client";
|
import { TaskStatus, TaskType } from "@prisma/client";
|
||||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "./TaskCard";
|
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "./TaskCard";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||||
|
|
||||||
const PRIORITY_DOT: Record<string, string> = {
|
const PRIORITY_DOT: Record<string, string> = {
|
||||||
LOW: "bg-zinc-500",
|
LOW: "bg-zinc-500",
|
||||||
@@ -34,7 +35,11 @@ interface KanbanTask {
|
|||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
priority: string;
|
priority: string;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
shot?: { shotCode: string; thumbnailUrl?: string | null } | null;
|
shot?: {
|
||||||
|
shotCode: string;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
generatedThumbnailUrl?: string | null;
|
||||||
|
} | null;
|
||||||
asset?: { assetCode: string; name: string } | null;
|
asset?: { assetCode: string; name: string } | null;
|
||||||
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||||
_count?: { versions: number };
|
_count?: { versions: number };
|
||||||
@@ -71,11 +76,12 @@ export function KanbanCard({ task, isDragOverlay = false }: KanbanCardProps) {
|
|||||||
|
|
||||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||||
|
|
||||||
// Resolve thumbnail: prefer latest version thumbnail, then shot thumbnail
|
// Resolve thumbnail: prefer latest version thumbnail, then the shot's own
|
||||||
|
// (reference frame first, render-derived one only as a fallback).
|
||||||
const thumbnail =
|
const thumbnail =
|
||||||
latestVersion?.thumbnailUrl ??
|
latestVersion?.thumbnailUrl ??
|
||||||
latestVersion?.posterUrl ??
|
latestVersion?.posterUrl ??
|
||||||
task.shot?.thumbnailUrl ??
|
shotThumbnail(task.shot) ??
|
||||||
null;
|
null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -137,7 +143,8 @@ export function KanbanCard({ task, isDragOverlay = false }: KanbanCardProps) {
|
|||||||
</span>
|
</span>
|
||||||
{latestVersion.approvalStatus === "APPROVED" ? (
|
{latestVersion.approvalStatus === "APPROVED" ? (
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
) : latestVersion.approvalStatus === "CHANGES_REQUESTED" ? (
|
) : latestVersion.approvalStatus === "NEEDS_CHANGES" ||
|
||||||
|
latestVersion.approvalStatus === "REJECTED" ? (
|
||||||
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||||
) : (
|
) : (
|
||||||
<Clock className="h-3.5 w-3.5 text-zinc-600" />
|
<Clock className="h-3.5 w-3.5 text-zinc-600" />
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { blenderManifestSchema, createBlenderJobSchema } from "@/lib/render-pipeline/blender";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The add-on and the server are separate codebases that only meet over JSON,
|
||||||
|
* so the exact payload the add-on builds is pinned here. A field renamed on
|
||||||
|
* either side otherwise surfaces as a 422 in front of an artist.
|
||||||
|
*/
|
||||||
|
describe("Blender submission contract", () => {
|
||||||
|
// Verbatim shape of scene_summary() in BlenderPanel/vfxreview_submit.py
|
||||||
|
const addonManifest = {
|
||||||
|
blendFile: "X:/shows/UNG/cg/UNG_110_030_010_smoke.blend",
|
||||||
|
scene: "Scene",
|
||||||
|
frameStart: 1001,
|
||||||
|
frameEnd: 1110,
|
||||||
|
outputPath: "X:/shows/UNG/cg/renders/smoke/frame_",
|
||||||
|
engine: "CYCLES",
|
||||||
|
blenderVersion: "4.2.1",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts what the add-on sends", () => {
|
||||||
|
const parsed = blenderManifestSchema.safeParse(addonManifest);
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the full submission body", () => {
|
||||||
|
const parsed = createBlenderJobSchema.safeParse({
|
||||||
|
projectCode: "UNG_S1",
|
||||||
|
label: "UNG_110_030_010_smoke",
|
||||||
|
manifest: addonManifest,
|
||||||
|
submittedByEmail: "chris@twotalesanimation.com",
|
||||||
|
urgent: false,
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the stage so the add-on need not send it", () => {
|
||||||
|
const parsed = blenderManifestSchema.parse(addonManifest);
|
||||||
|
expect(parsed.stage).toBe("BLENDER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a backwards frame range rather than queueing a no-op render", () => {
|
||||||
|
const parsed = blenderManifestSchema.safeParse({ ...addonManifest, frameEnd: 900 });
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts EEVEE, which is what the add-on reports on a non-Cycles scene", () => {
|
||||||
|
expect(blenderManifestSchema.safeParse({ ...addonManifest, engine: "BLENDER_EEVEE_NEXT" }).success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user