Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a7ba689ed | |||
| 41cf79abbe | |||
| 7a7088f2ad | |||
| 0aedc4b1fa | |||
| d7e37de18f | |||
| 06a4122ebb | |||
| 842a8fe036 | |||
| 34af26dfbf | |||
| 27d9618995 | |||
| 1d60a55b3c | |||
| 88112112d4 | |||
| 91e69f0bcb | |||
| 477aef7b27 | |||
| f30acef101 | |||
| 326d8cbefa | |||
| b596c97faf | |||
| 824695ef30 | |||
| 390d7a21ac | |||
| 5e29c3b88d | |||
| c2509512ff | |||
| 27f9f7ac64 | |||
| 9660cdc21c | |||
| bae46c2968 | |||
| 2e9fa45580 | |||
| 2817c7e815 | |||
| 428814b8b9 | |||
| 45459e4b12 | |||
| 2b373737d8 | |||
| 724b0cbefe | |||
| 0c4f94d56a | |||
| 40b99f3add | |||
| 33bc6a935c | |||
| 532ebd82d7 | |||
| 708e8a0d8f | |||
| 97246bcbc3 | |||
| 400cf3f6ea | |||
| 2dee1bf6fc | |||
| 469d782b4f | |||
| 6afbe82a29 | |||
| 6bfc013e35 | |||
| f6a7f0ef5a | |||
| fe420b9455 | |||
| 2a8888089f | |||
| a073f92da3 | |||
| 04e5eb123a | |||
| 75f9258b69 | |||
| 269a5cbfda |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
+66
-9
@@ -20,9 +20,14 @@ This is the operational guide. The design rationale and normative rules live in
|
||||
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**.
|
||||
4. A human uploads that folder to the client and clicks **Record upload**,
|
||||
pasting the link to where it now lives.
|
||||
5. They send the client that link and click **Share link**, recording who it
|
||||
went to. Only then is the package `DELIVERED`.
|
||||
|
||||
Nothing is ever sent to the client automatically. Step 4 is deliberately manual.
|
||||
Nothing is ever sent to the client automatically. Steps 4 and 5 are deliberately
|
||||
manual — but until both are recorded the package sits in **Awaiting delivery**
|
||||
at the top of the dashboard.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,8 +74,8 @@ Watch the package status on the same page:
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `READY` | Built and verified. Shown as *Built — not uploaded*, then *Uploaded — link not shared* |
|
||||
| `DELIVERED` | The upload location and who the link was sent to are both on record |
|
||||
| `FAILED` | Something did not verify — see the error and the per-shot list |
|
||||
| `CANCELLED` | Abandoned before delivery |
|
||||
|
||||
@@ -166,11 +171,59 @@ so it is logged and the package continues without it.
|
||||
|
||||
## Marking delivered
|
||||
|
||||
Once you have uploaded the folder, **Mark delivered** moves every packaged
|
||||
export to `DELIVERED`. Requires admin, producer or supervisor.
|
||||
A built package is not a delivered one, so delivery is two recorded steps. Both
|
||||
require 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.
|
||||
1. **Record upload** — paste the link to the folder on the client's portal and
|
||||
confirm it matches the shot, frame and MOV counts shown. The link can be
|
||||
corrected until it is shared.
|
||||
2. **Share link** — copy the generated message (link, shots, versions), send it,
|
||||
and record who it went to. This moves every packaged export to `DELIVERED`.
|
||||
|
||||
The second step is refused until the first is on record. Meanwhile the package
|
||||
is listed under **Awaiting delivery** on the dashboard, amber after a day and
|
||||
red after two.
|
||||
|
||||
This is still a human statement of fact — the client's portal has no API, so
|
||||
the app cannot see the upload itself. What it guarantees is that nothing reads
|
||||
as delivered without a link, a recipient, a name and a time behind it. Packages
|
||||
delivered before this existed show as delivered with no evidence recorded.
|
||||
|
||||
---
|
||||
|
||||
## Delivery requests
|
||||
|
||||
When a client asks for something ("EXRs for 116"), log it so it cannot be
|
||||
forgotten. Requires admin, producer or supervisor.
|
||||
|
||||
- **Dashboard → Awaiting delivery → Log request**, or the same button on
|
||||
**Pipeline → Deliveries**: pick the project, then a whole episode or a pasted
|
||||
list of shot codes (ours or the client's; a code that matches nothing is
|
||||
refused rather than skipped).
|
||||
- **Shot Status → Bulk Actions → Log Delivery Request** for a ticked selection.
|
||||
|
||||
The shot list is a snapshot taken when the request is logged. The request then
|
||||
sits in **Awaiting delivery** showing *"3 of 9 delivered"*, and on the
|
||||
deliveries page every shot shows where it stands: not through QC, in QC, ready
|
||||
to package, building, built, uploaded, delivered. **Build N ready** packages
|
||||
the shots that have passed QC.
|
||||
|
||||
A request has no "done" button. A shot's line closes only when:
|
||||
|
||||
- a package containing it is shared (step 2 of marking delivered) after the
|
||||
request was logged, or
|
||||
- it is marked **Delivered Outside Pipeline** with the upload link and who the
|
||||
link was sent to, dated on or after the day the request was logged. For a
|
||||
shot on an open request those two fields are required.
|
||||
|
||||
The request closes when every line has. A delivery made *before* the request
|
||||
was logged does not count — a client asking again is asking for it again. The
|
||||
only other way out is **Cancel request**, which needs a reason and stays in the
|
||||
history.
|
||||
|
||||
The *Wanted* field (EXRs, MOVs, both) records what was asked for; it does not
|
||||
change what closes a line. A MOV-only ask is closed through Delivered Outside
|
||||
Pipeline, since a MOV review package has no delivered state.
|
||||
|
||||
---
|
||||
|
||||
@@ -233,8 +286,12 @@ a different spec is a config change rather than a code change.
|
||||
| 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}/uploaded` | Record the upload: `{ uploadUrl, contentsConfirmed: true }` |
|
||||
| E19 | `POST` | `/api/deliveries/{id}/delivered` | Record the share and mark delivered: `{ sharedWith, note? }` |
|
||||
| — | `POST` | `/api/deliveries/{id}/cancel` | Cancel a package |
|
||||
| — | `GET` | `/api/delivery-requests` | List requests with per-shot stages (`?projectId=`, `?open=1`) |
|
||||
| — | `POST` | `/api/delivery-requests` | Log a request: `{ projectId, episode \| shotIds \| shotCodes, deliverable, requestedBy, dueDate?, note? }` |
|
||||
| — | `POST` | `/api/delivery-requests/{id}/cancel` | Cancel a request: `{ reason }` |
|
||||
| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | Worker reports a build (API key) |
|
||||
|
||||
`POST /api/deliveries` takes `{ projectId, episode?, exportIds?, options? }`.
|
||||
|
||||
@@ -1073,6 +1073,8 @@ Timecode is the check this engine exists for. It is validated **against the sour
|
||||
| 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 |
|
||||
|
||||
**Several delivered elements.** When the rank-1 timing plate does not match, the worker tries the manifest's other `plates[]` before failing — the panel's longest-duration pick is not always the element the comp was anchored to. A match against one of them is a PASS naming that element; no match still FAILs against the timing plate, listing the others tried. Only delivered footage qualifies: plates under a configured plate search root, or (with none configured) alongside the timing plate in its footage folder. `plates[]` also holds pre-renders, which agree with the render by construction and must never rescue it.
|
||||
|
||||
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:**
|
||||
@@ -1177,6 +1179,27 @@ Not every export is framed like its review. A deliverable rendered unscaled or w
|
||||
|
||||
With no earlier export to inherit from, the export still reaches READY_FOR_QC and the timeline records that it has no review media.
|
||||
|
||||
### 9.5 Handles in review (metadata only)
|
||||
|
||||
Renders run past the cut at each end (8 + 8 by house convention). Handles ship with the EXRs and stay in the preview, but reviewers must be able to tell them from the cut, or a glitch in a handle gets flagged as a problem in the shot. **Nothing is trimmed or burned in** — this is deliberately not the reverted picture-lock trim (which desynced the overlay timecode); the render and every delivered file are unchanged.
|
||||
|
||||
- **Panel:** "Includes handles [8] frames each end" (default on, prefilled from Handle Frames) sends `manifest.review = { handleHead, handleTail }`; unticked sends 0/0. Manifests without `review` (older panels) fall back to `source.handleFrames` (`manifestHandles`, exports.ts).
|
||||
- **Export → Version:** `createExport` stores `Export.handleHead/handleTail`; `finalizePreview` copies them onto the Version, plus `pictureStartFrame` = the head slate length from the build's `pictureStartSeconds` (worker ≥ 1.6.4). Left null, the player infers the slate as MP4 frames − `frameCount` (the EXR count), within 0–5 frames.
|
||||
- **Player** (`lib/handles.ts` → `computeEditRange`): the timeline hatches the handles amber and labels them HANDLE (the slate, SLATE); while the playhead is in a handle the picture gets an amber frame and a "HEAD/TAIL HANDLE · NOT IN EDIT" tag; notes on handle frames carry a HANDLE badge. "Edit range only" (scissors) keeps play, reverse and go-to-start/end inside the cut — on by default in the client portal, off for staff.
|
||||
- **Staff override:** the review page's Handles button edits a version's head/tail/slate — for manual uploads, or to correct a render.
|
||||
- **Backfill** (`20260922000000_version_handles`): every existing export takes its manifest's `source.handleFrames` (else 8), and the Versions they produced take the same.
|
||||
|
||||
### 9.6 High quality playback and the ProRes download
|
||||
|
||||
The Standard review MP4 is AE's H.264 (UHD, ~35 Mbps). ProRes itself can't be the player's source — only Safari on a Mac decodes it in a web page, and UHD ProRes LT is ~280 Mbps — so the worker (≥ 1.7.0, when `preview.highQuality` is on) adds two things after the AE render, both **best-effort** (a failure costs the extras, never the preview):
|
||||
|
||||
- **High quality MP4:** ffmpeg re-encodes the delivery MOV as full-res H.264 High, CRF 14 capped at 100 Mbps, yuv420p, faststart → `Version.hqFileUrl`. The player shows an **HQ** switch when present; switching keeps frame, play state, speed and zoom, is remembered per browser (`review:quality`), and falls back to Standard if the file won't load. Same comp as the Standard MP4, so slate and handles line up frame for frame.
|
||||
- **ProRes download:** the MOV itself is uploaded (presign kind `highres`) → `Version.highResKey/highResFilename/highResSize`. It is the version's **High Res** download in the client portal (named with the client code, `clientHighResFilename`) and a **ProRes** button on the staff review page. The shot's manually uploaded High Res remains the fallback for versions without one. Since worker 1.7.1 the MOV uploads **in the background after finalize** (`HighResUploader`, queue in `%ProgramData%\VFXReviewWorker\highres-queue`, resumes after a restart, 5 attempts) and attaches via `POST /api/ext/render/jobs/{id}/attach-highres` (E13b); a MOV from a preview that a newer preview has superseded is discarded. While a preview job uploads, the worker replaces the job's log tail with live upload status. Since 1.7.2, artifacts of 16 MB or more upload as S3 multipart: `artifact-presign` with `multipart: true, size` returns `uploadId`, `partSize` (8 MB minimum) and one presigned URL per part; the worker PUTs parts on `uploadConcurrency` connections (default 16), retries each part up to 4 times, then calls `artifact-complete` (E20b) to stitch them, or with `abort: true` to discard them. A single connection from the studio to nbg1 is capped near 0.1 MB/s.
|
||||
|
||||
A lease keep-alive runs during the encode and uploads (they can outlast the 300 s lease). The export timeline records what was attached.
|
||||
|
||||
**Housekeeping** (`lib/render-pipeline/high-res.ts`): a version keeps its HQ MP4 and ProRes while it is the task's latest, shared with the client, approved, or younger than `preview.highResRetentionDays` (14). Otherwise both are deleted from storage and the row gets `highResPrunedAt`; the Standard MP4 is never touched. It runs for the task after every finalize and studio-wide hourly from the reaper; deleting a version deletes its files. A reslate replaces (and deletes) the version's previous HQ/ProRes.
|
||||
|
||||
---
|
||||
|
||||
## 10. QC Workflow
|
||||
|
||||
@@ -208,6 +208,9 @@ read back from the assembly, so a machine can never report a version it is not r
|
||||
`pathMappings` translate the manifest's canonical UNC paths to this machine's drive
|
||||
mappings. `plateSearchRoots` is optional: it is only consulted when a manifest carries no
|
||||
timing plate (older panel versions), to find the shot's plates for the timecode check.
|
||||
`uploadConcurrency` (default 16) is how many parallel connections a file of 16 MB or more
|
||||
uploads over. Each connection to object storage is capped near 0.1 MB/s from the studio, so
|
||||
throughput scales with it (measured 2026-10-01: 1 → 0.13 MB/s, 8 → 0.64, 24 → 1.69 MB/s).
|
||||
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.
|
||||
|
||||
|
||||
@@ -73,6 +73,46 @@ public class BlenderProgressParserTests
|
||||
Assert.Equal(1002, frames[^1]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blender 5.x prefixes every line with elapsed time and a log category.
|
||||
/// Anchored on "Saved:", the parser counted no frames from a render that
|
||||
/// wrote every one, and the job failed as "saved no frames".
|
||||
/// These lines are verbatim Blender 5.2 output.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ParsesBlender5PrefixedOutput()
|
||||
{
|
||||
Assert.Equal(@"X:\renders\frame_0001.png",
|
||||
BlenderProgressParser.ParseSaved(@"00:02.954 render | Saved: 'X:\renders\frame_0001.png'"));
|
||||
Assert.Equal(1, BlenderProgressParser.ParseFrame("00:02.469 render | Fra: 1 | Mem: 0M | Synchronizing object | Cube"));
|
||||
Assert.Null(BlenderProgressParser.ParseSaved("00:02.954 render | Time: 00:00.87 (Saving: 00:00.14)"));
|
||||
Assert.Null(BlenderProgressParser.ParseFrame("00:02.079 render | Rendering frame 1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecognisesBlender5Errors()
|
||||
{
|
||||
const string unwritable = @"00:03.063 reports | ERROR Render error (No such file or directory) cannot save: 'Q:\out\f_0001.png'";
|
||||
Assert.True(BlenderProgressParser.IsErrorLine(unwritable));
|
||||
Assert.True(BlenderProgressParser.IsNonRetryableError(unwritable));
|
||||
|
||||
const string missing = @"00:13.250 reports | ERROR Cannot read file ""X:\gone.blend"": No such file or directory";
|
||||
Assert.True(BlenderProgressParser.IsErrorLine(missing));
|
||||
Assert.True(BlenderProgressParser.IsNonRetryableError(missing));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add-ons print warnings on every launch; one must never become the
|
||||
/// reported reason a render failed.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Warning: add-on missing 'bl_info', this can cause poor performance!: 'G:\\addons\\x\\__init__.py'")]
|
||||
[InlineData("00:01.000 addon | WARNING something harmless")]
|
||||
public void WarningsAreNotErrors(string line)
|
||||
{
|
||||
Assert.False(BlenderProgressParser.IsErrorLine(line));
|
||||
}
|
||||
|
||||
/// <summary>Mirrors BlenderRunner.PumpAsync: CR and LF both end a line.</summary>
|
||||
private static List<string> SplitLikeThePump(string text)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using VFXReviewWorker;
|
||||
using Xunit;
|
||||
|
||||
namespace VFXReviewWorker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Large artifacts go up as parallel multipart parts: from the studio a single
|
||||
/// connection to object storage is capped near 0.1 MB/s, so a ProRes MOV could
|
||||
/// not finish inside the 30 min request timeout. These drive the real upload
|
||||
/// code against a local fake of the server + storage.
|
||||
/// </summary>
|
||||
public class MultipartUploadTests : IDisposable
|
||||
{
|
||||
private const int PartSize = 8 * 1024 * 1024;
|
||||
|
||||
private readonly HttpListener _listener = new();
|
||||
private readonly string _base;
|
||||
private readonly ConcurrentDictionary<int, byte[]> _parts = new();
|
||||
private readonly ConcurrentDictionary<int, int> _failuresLeft = new();
|
||||
private readonly ConcurrentBag<string> _completions = new();
|
||||
private int _inFlight, _maxInFlight;
|
||||
private bool _multipartSupported = true;
|
||||
private byte[]? _singlePut;
|
||||
|
||||
public MultipartUploadTests()
|
||||
{
|
||||
var port = FreePort();
|
||||
_base = $"http://localhost:{port}/";
|
||||
_listener.Prefixes.Add(_base);
|
||||
_listener.Start();
|
||||
_ = Task.Run(Serve);
|
||||
}
|
||||
|
||||
private static int FreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
var port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private async Task Serve()
|
||||
{
|
||||
while (_listener.IsListening)
|
||||
{
|
||||
HttpListenerContext ctx;
|
||||
try { ctx = await _listener.GetContextAsync(); } catch { return; }
|
||||
_ = Task.Run(() => Handle(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Handle(HttpListenerContext ctx)
|
||||
{
|
||||
var path = ctx.Request.Url!.AbsolutePath;
|
||||
using var ms = new MemoryStream();
|
||||
await ctx.Request.InputStream.CopyToAsync(ms);
|
||||
var body = ms.ToArray();
|
||||
var res = ctx.Response;
|
||||
|
||||
if (path.EndsWith("/artifact-presign"))
|
||||
{
|
||||
var req = JsonDocument.Parse(body).RootElement;
|
||||
var size = req.GetProperty("size").GetInt64();
|
||||
object payload = _multipartSupported && req.GetProperty("multipart").GetBoolean()
|
||||
? new
|
||||
{
|
||||
key = "highres/x.mov",
|
||||
uploadId = "up1",
|
||||
partSize = PartSize,
|
||||
partUrls = Enumerable.Range(1, (int)((size + PartSize - 1) / PartSize))
|
||||
.Select(n => $"{_base}part/{n}").ToList(),
|
||||
}
|
||||
: new { key = "highres/x.mov", presignedUrl = $"{_base}single" };
|
||||
await Json(res, payload);
|
||||
}
|
||||
else if (path.StartsWith("/part/"))
|
||||
{
|
||||
var n = int.Parse(path["/part/".Length..]);
|
||||
var now = Interlocked.Increment(ref _inFlight);
|
||||
InterlockedMax(ref _maxInFlight, now);
|
||||
await Task.Delay(50);
|
||||
Interlocked.Decrement(ref _inFlight);
|
||||
if (_failuresLeft.TryGetValue(n, out var left) && left > 0)
|
||||
{
|
||||
_failuresLeft[n] = left - 1;
|
||||
res.StatusCode = 500;
|
||||
res.Close();
|
||||
return;
|
||||
}
|
||||
_parts[n] = body;
|
||||
res.Headers["ETag"] = $"\"etag-{n}\"";
|
||||
res.Close();
|
||||
}
|
||||
else if (path == "/single")
|
||||
{
|
||||
_singlePut = body;
|
||||
res.Close();
|
||||
}
|
||||
else if (path.EndsWith("/artifact-complete"))
|
||||
{
|
||||
_completions.Add(System.Text.Encoding.UTF8.GetString(body));
|
||||
await Json(res, new { ok = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
res.StatusCode = 404;
|
||||
res.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void InterlockedMax(ref int target, int value)
|
||||
{
|
||||
int seen;
|
||||
while ((seen = Volatile.Read(ref target)) < value && Interlocked.CompareExchange(ref target, value, seen) != seen) { }
|
||||
}
|
||||
|
||||
private static async Task Json(HttpListenerResponse res, object payload)
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, WorkerOptions.JsonOpts);
|
||||
res.ContentType = "application/json";
|
||||
await res.OutputStream.WriteAsync(bytes);
|
||||
res.Close();
|
||||
}
|
||||
|
||||
private ApiClient Client(int concurrency = 4) =>
|
||||
new(new WorkerOptions { ServerUrl = _base, ApiKey = "k", UploadConcurrency = concurrency },
|
||||
NullLogger<ApiClient>.Instance);
|
||||
|
||||
private static string TempFile(int bytes)
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
var data = new byte[bytes];
|
||||
new Random(42).NextBytes(data);
|
||||
File.WriteAllBytes(path, data);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uploads_parts_in_parallel_and_stitches_them_in_order()
|
||||
{
|
||||
var file = TempFile(PartSize * 5 + 12345);
|
||||
long lastProgress = 0;
|
||||
var key = await Client(4).UploadArtifactAsync("job1", "m1", "highres", file, "video/quicktime",
|
||||
CancellationToken.None, sent => Interlocked.Exchange(ref lastProgress, sent));
|
||||
|
||||
Assert.Equal("highres/x.mov", key);
|
||||
var stitched = Enumerable.Range(1, 6).SelectMany(n => _parts[n]).ToArray();
|
||||
Assert.Equal(File.ReadAllBytes(file), stitched);
|
||||
Assert.True(_maxInFlight > 1, "parts should upload concurrently");
|
||||
Assert.True(_maxInFlight <= 4, "concurrency is capped by UploadConcurrency");
|
||||
Assert.Equal(new FileInfo(file).Length, Interlocked.Read(ref lastProgress));
|
||||
|
||||
var complete = JsonDocument.Parse(Assert.Single(_completions)).RootElement;
|
||||
Assert.False(complete.TryGetProperty("abort", out _));
|
||||
Assert.Equal(6, complete.GetProperty("parts").GetArrayLength());
|
||||
Assert.Equal("\"etag-1\"", complete.GetProperty("parts")[0].GetProperty("etag").GetString());
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_part_is_retried_on_its_own()
|
||||
{
|
||||
var file = TempFile(PartSize * 3);
|
||||
_failuresLeft[2] = 2;
|
||||
var key = await Client().UploadArtifactAsync("job1", "m1", "highres", file, "video/quicktime", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(key);
|
||||
Assert.Equal(File.ReadAllBytes(file), Enumerable.Range(1, 3).SelectMany(n => _parts[n]).ToArray());
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_part_that_keeps_failing_aborts_the_upload()
|
||||
{
|
||||
var file = TempFile(PartSize * 3);
|
||||
_failuresLeft[3] = 99;
|
||||
var key = await Client().UploadArtifactAsync("job1", "m1", "highres", file, "video/quicktime", CancellationToken.None);
|
||||
|
||||
Assert.Null(key);
|
||||
var abort = JsonDocument.Parse(Assert.Single(_completions)).RootElement;
|
||||
Assert.True(abort.GetProperty("abort").GetBoolean());
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Falls_back_to_a_single_put_on_a_server_without_multipart()
|
||||
{
|
||||
_multipartSupported = false;
|
||||
var file = TempFile(PartSize * 3);
|
||||
var key = await Client().UploadArtifactAsync("job1", "m1", "highres", file, "video/quicktime", CancellationToken.None);
|
||||
|
||||
Assert.Equal("highres/x.mov", key);
|
||||
Assert.Equal(File.ReadAllBytes(file), _singlePut);
|
||||
Assert.Empty(_completions);
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
public void Dispose() => _listener.Close();
|
||||
}
|
||||
@@ -70,6 +70,59 @@ public class PrepareOutputDirTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The version folder holds the preview MOV/MP4 built from the same
|
||||
/// basename. A shot re-rendered at the same version — the "No preview"
|
||||
/// workflow, where the EXRs are re-exported unscaled and the earlier MOV is
|
||||
/// inherited as the review media — must not have that MOV cleared out from
|
||||
/// under it before aerender starts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void KeepsPreviewMediaSharingThePatternPrefix()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"), "old");
|
||||
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.mov"), "delivery mov");
|
||||
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.mp4"), "review mp4");
|
||||
|
||||
AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr");
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.mov")));
|
||||
Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.mp4")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearsOnlyThisVersionsFrames()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "vfxr-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr"), "old");
|
||||
File.WriteAllText(Path.Combine(dir, "SHOT_cmp_TT_v005.1001.exr"), "other version");
|
||||
File.WriteAllText(Path.Combine(dir, "OTHERSHOT_cmp_TT_v004.1001.exr"), "other shot");
|
||||
|
||||
AerenderRunner.PrepareOutputDir(dir, "SHOT_cmp_TT_v004.[####].exr");
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v004.1001.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(dir, "SHOT_cmp_TT_v005.1001.exr")));
|
||||
Assert.True(File.Exists(Path.Combine(dir, "OTHERSHOT_cmp_TT_v004.1001.exr")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreatesMissingDirectory()
|
||||
{
|
||||
|
||||
@@ -117,4 +117,21 @@ public class LogTailTests
|
||||
for (var i = 1; i <= 5; i++) tail.Add($"line {i}");
|
||||
Assert.Equal("line 3\nline 4\nline 5", tail.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadingWhileAnotherThreadWritesDoesNotThrow()
|
||||
{
|
||||
// The Blender runner's output pumps add lines while its progress loop
|
||||
// reads the tail; unsynchronised this threw "Collection was modified".
|
||||
var tail = new LogTail(200);
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
var writer = Task.Run(() =>
|
||||
{
|
||||
var i = 0;
|
||||
while (!stop.IsCancellationRequested) tail.Add($"Fra:1001 Sample {i++}/128");
|
||||
});
|
||||
|
||||
while (!stop.IsCancellationRequested) _ = tail.ToString();
|
||||
await writer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,74 @@ public class ValidationStageTests : IDisposable
|
||||
Assert.Contains("8-frame handle", tc.Message);
|
||||
}
|
||||
|
||||
/// <summary>Writes a one-frame plate into a footage-style element folder.</summary>
|
||||
private string WritePlate(string element, string timecode, int fps = 24)
|
||||
{
|
||||
var plate = Path.Combine(_dir, "footage", $"UNG_107_051_010_{element}_TT_v001", $"UNG_107_051_010_{element}_TT_v001.01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(plate)!);
|
||||
File.Move(ExrFixture.Write(timecode, 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, fps, 4), plate);
|
||||
return plate;
|
||||
}
|
||||
|
||||
private static ManifestSource Source(string timingPlate, params string[] plates) => new()
|
||||
{
|
||||
TimingPlate = new PlateRef { Path = timingPlate, FrameStart = 1001 },
|
||||
Plates = plates.Select(p => new PlateRef { Path = p, FrameStart = 1001 }).ToList(),
|
||||
HandleFrames = 8,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AnotherDeliveredPlatePassesWhenTheTimingPlateDoesNot()
|
||||
{
|
||||
// The comp was anchored to FG01, but the panel named BG01 (longest) as
|
||||
// the timing plate — the two carry unrelated camera timecodes.
|
||||
WriteSequence(105, "09:12:00:00");
|
||||
var bg = WritePlate("BG01", "14:26:31:08");
|
||||
var fg = WritePlate("FG01", "09:12:00:00");
|
||||
|
||||
var manifest = Manifest(_dir, null, 1001);
|
||||
manifest.Source = Source(bg, bg, fg);
|
||||
|
||||
var tc = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match");
|
||||
Assert.Equal("PASS", tc.Status);
|
||||
Assert.Contains("UNG_107_051_010_FG01_TT_v001.01001.exr", tc.Message);
|
||||
Assert.Contains("BG01", tc.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMatchingPlateStillFailsAgainstTheTimingPlate()
|
||||
{
|
||||
WriteSequence(105, "14:26:31:16");
|
||||
var bg = WritePlate("BG01", "14:26:31:08");
|
||||
var fg = WritePlate("FG01", "09:12:00:00");
|
||||
|
||||
var manifest = Manifest(_dir, null, 1001);
|
||||
manifest.Source = Source(bg, bg, fg);
|
||||
|
||||
var tc = Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match");
|
||||
Assert.Equal("FAIL", tc.Status);
|
||||
Assert.Equal("14:26:31:08", tc.Expected);
|
||||
Assert.Contains("8-frame handle", tc.Message);
|
||||
Assert.Contains("1 other delivered plate", tc.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APreRenderIsNeverUsedAsTheFallbackPlate()
|
||||
{
|
||||
// plates[] is every file in the comp. A pre-render was rendered from
|
||||
// this comp, so it agrees with the render whether or not either is right.
|
||||
WriteSequence(105, "14:26:31:16");
|
||||
var bg = WritePlate("BG01", "14:26:31:08");
|
||||
var preRender = Path.Combine(_dir, "renders", "prerender", "UNG_107_051_010_PRERENDER_01001.exr");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(preRender)!);
|
||||
File.Move(ExrFixture.Write("14:26:31:16", 3840, 2160, new[] { ("B", 1), ("G", 1), ("R", 1) }, 24, 4), preRender);
|
||||
|
||||
var manifest = Manifest(_dir, null, 1001);
|
||||
manifest.Source = Source(bg, bg, preRender);
|
||||
|
||||
Assert.Equal("FAIL", Check(NewStage().Run(manifest, _dir, new ServerConfig()), "timecode_plate_match").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APlateAtADifferentRateFailsPlateFps()
|
||||
{
|
||||
|
||||
@@ -234,26 +234,30 @@ public sealed class AerenderRunner
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// §7.3: outputDir is created before launch; a pre-existing non-empty dir
|
||||
/// can only hold output from a previous crashed attempt of this same
|
||||
/// immutable version. Safety guard: only files matching the job's own
|
||||
/// output pattern prefix are ever deleted.
|
||||
/// §7.3: outputDir is created before launch; a pre-existing dir can hold
|
||||
/// frames from a previous crashed attempt, which aerender would otherwise
|
||||
/// leave interleaved with this attempt's. Safety guard: only files matching
|
||||
/// the job's own output pattern — its prefix *and* its extension — are ever
|
||||
/// deleted.
|
||||
///
|
||||
/// The extension is not optional. The version folder is shared with the
|
||||
/// preview stage, whose MOV and MP4 are built from the same basename
|
||||
/// (preview.ts: `{outputDir}/{base}.mov`), so prefix alone also matches
|
||||
/// them. A version folder can legitimately be rendered into twice — the
|
||||
/// output path is keyed on the shot's manually-set version, not on the
|
||||
/// export — and on a no-preview re-render (Export.skipPreview) the media
|
||||
/// that gets inherited is precisely the MOV sitting in this folder.
|
||||
/// Deleting it left the export pointing at a file that no longer existed,
|
||||
/// and delivery silently packaged the EXRs without their viewing copy.
|
||||
/// </summary>
|
||||
/// <summary>Filename prefix of an output pattern ("X.[####].exr" → "X").</summary>
|
||||
internal static string OutputPatternPrefix(string outputPattern)
|
||||
{
|
||||
var bracket = outputPattern.IndexOf('[');
|
||||
return bracket > 0 ? outputPattern[..bracket].TrimEnd('.') : Path.GetFileNameWithoutExtension(outputPattern);
|
||||
}
|
||||
|
||||
internal static void PrepareOutputDir(string outputDirLocal, string outputPattern)
|
||||
{
|
||||
Directory.CreateDirectory(outputDirLocal);
|
||||
var prefix = OutputPatternPrefix(outputPattern);
|
||||
if (string.IsNullOrEmpty(prefix)) return;
|
||||
var pattern = SequencePattern.FromOutputPattern(outputPattern);
|
||||
if (string.IsNullOrEmpty(pattern.Prefix)) return;
|
||||
foreach (var file in Directory.EnumerateFiles(outputDirLocal))
|
||||
{
|
||||
if (Path.GetFileName(file).StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
if (pattern.MatchesOutputFile(Path.GetFileName(file)))
|
||||
{
|
||||
try { File.Delete(file); } catch { /* locked leftovers surface as an aerender error */ }
|
||||
}
|
||||
@@ -276,11 +280,19 @@ public sealed class AerenderRunner
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ring buffer of the last N output lines (§5.3 logTail).</summary>
|
||||
/// <summary>
|
||||
/// Ring buffer of the last N output lines (§5.3 logTail).
|
||||
///
|
||||
/// Thread-safe: output readers add lines while the progress loop reads the
|
||||
/// tail for its report. Unsynchronised, ToString enumerates the queue while a
|
||||
/// reader enqueues and throws "Collection was modified" — Blender, which
|
||||
/// writes a status line many times a second, hit this on every job.
|
||||
/// </summary>
|
||||
public sealed class LogTail
|
||||
{
|
||||
private readonly Queue<string> _lines;
|
||||
private readonly int _capacity;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public LogTail(int capacity)
|
||||
{
|
||||
@@ -290,9 +302,15 @@ public sealed class LogTail
|
||||
|
||||
public void Add(string line)
|
||||
{
|
||||
_lines.Enqueue(line);
|
||||
while (_lines.Count > _capacity) _lines.Dequeue();
|
||||
lock (_gate)
|
||||
{
|
||||
_lines.Enqueue(line);
|
||||
while (_lines.Count > _capacity) _lines.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => string.Join('\n', _lines);
|
||||
public override string ToString()
|
||||
{
|
||||
lock (_gate) return string.Join('\n', _lines);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,16 @@ namespace VFXReviewWorker;
|
||||
public sealed class ApiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly WorkerOptions _options;
|
||||
private readonly ILogger<ApiClient> _log;
|
||||
|
||||
// Presigned uploads: a separate client so the URL is not sent with our
|
||||
// Authorization header, and a generous per-request timeout for media.
|
||||
private static readonly HttpClient _upload = new() { Timeout = TimeSpan.FromMinutes(30) };
|
||||
|
||||
public ApiClient(WorkerOptions options, ILogger<ApiClient> log)
|
||||
{
|
||||
_options = options;
|
||||
_log = log;
|
||||
_http = new HttpClient
|
||||
{
|
||||
@@ -124,6 +130,7 @@ public sealed class ApiClient
|
||||
"validation" => $"api/ext/render/jobs/{jobId}/validation",
|
||||
"delivery" => $"api/ext/render/jobs/{jobId}/delivery",
|
||||
"finalize" => $"api/ext/render/jobs/{jobId}/finalize",
|
||||
"attach-highres" => $"api/ext/render/jobs/{jobId}/attach-highres",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "unknown lifecycle report kind"),
|
||||
};
|
||||
var res = await _http.PostAsJsonAsync(path, payload, Json, ct);
|
||||
@@ -143,8 +150,10 @@ public sealed class ApiClient
|
||||
}
|
||||
|
||||
// E20 + PUT upload. Returns the storage key, or null when the upload failed.
|
||||
// onProgress receives the bytes sent so far, for status lines in the app.
|
||||
public async Task<string?> UploadArtifactAsync(
|
||||
string jobId, string machineId, string kind, string filePath, string contentType, CancellationToken ct)
|
||||
string jobId, string machineId, string kind, string filePath, string contentType, CancellationToken ct,
|
||||
Action<long>? onProgress = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -153,6 +162,13 @@ public sealed class ApiClient
|
||||
_log.LogWarning("Artifact {Kind} not found at {Path}", kind, filePath);
|
||||
return null;
|
||||
}
|
||||
var size = new FileInfo(filePath).Length;
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
// Uploads can run for many minutes on a slow uplink; without these two
|
||||
// lines the log goes silent and a busy worker looks hung.
|
||||
if (kind != "log")
|
||||
_log.LogInformation("Uploading {Kind} for job {JobId}: {File} ({MB:F1} MB)",
|
||||
kind, jobId, Path.GetFileName(filePath), size / 1048576.0);
|
||||
|
||||
var presignRes = await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-presign", new
|
||||
{
|
||||
@@ -160,6 +176,8 @@ public sealed class ApiClient
|
||||
kind,
|
||||
fileName = Path.GetFileName(filePath),
|
||||
contentType,
|
||||
multipart = size >= MultipartThreshold,
|
||||
size,
|
||||
}, Json, ct);
|
||||
if (!presignRes.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -169,18 +187,27 @@ public sealed class ApiClient
|
||||
var presign = await presignRes.Content.ReadFromJsonAsync<PresignResponse>(Json, ct);
|
||||
if (presign is null) return null;
|
||||
|
||||
// Media files can be large — generous timeout, separate client so the
|
||||
// presigned URL is not sent with our Authorization header.
|
||||
using var upload = new HttpClient { Timeout = TimeSpan.FromMinutes(30) };
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var content = new StreamContent(stream);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
var putRes = await upload.PutAsync(presign.PresignedUrl, content, ct);
|
||||
if (!putRes.IsSuccessStatusCode)
|
||||
if (presign.UploadId is not null && presign.PartUrls is { Count: > 0 } && presign.PartSize > 0)
|
||||
{
|
||||
_log.LogWarning("Upload of {Kind} failed: {Status}", kind, (int)putRes.StatusCode);
|
||||
return null;
|
||||
if (!await UploadMultipartAsync(jobId, machineId, kind, filePath, size, presign, onProgress, ct))
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single PUT: small files, or a server that predates multipart.
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var content = new ProgressStreamContent(stream, onProgress);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
var putRes = await _upload.PutAsync(presign.PresignedUrl, content, ct);
|
||||
if (!putRes.IsSuccessStatusCode)
|
||||
{
|
||||
_log.LogWarning("Upload of {Kind} failed after {Sec:F0}s: {Status}", kind, sw.Elapsed.TotalSeconds, (int)putRes.StatusCode);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (kind != "log")
|
||||
_log.LogInformation("Uploaded {Kind} for job {JobId} in {Sec:F0}s ({Speed:F1} MB/s)",
|
||||
kind, jobId, sw.Elapsed.TotalSeconds, size / 1048576.0 / Math.Max(0.001, sw.Elapsed.TotalSeconds));
|
||||
return presign.Key;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -190,6 +217,117 @@ public sealed class ApiClient
|
||||
}
|
||||
}
|
||||
|
||||
// Files at least this big go up as parallel multipart parts (E20b).
|
||||
private const long MultipartThreshold = 16L * 1024 * 1024;
|
||||
private const int PartAttempts = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the parts concurrently (UploadConcurrency connections), each
|
||||
/// retried on its own so a dropped connection costs one part, not the
|
||||
/// file; then asks the server to stitch them. Aborts on failure so the
|
||||
/// stored parts don't linger in the bucket.
|
||||
/// </summary>
|
||||
private async Task<bool> UploadMultipartAsync(string jobId, string machineId, string kind, string filePath,
|
||||
long size, PresignResponse presign, Action<long>? onProgress, CancellationToken ct)
|
||||
{
|
||||
var partUrls = presign.PartUrls!;
|
||||
var partSize = presign.PartSize;
|
||||
var etags = new string?[partUrls.Count];
|
||||
var sentPerPart = new long[partUrls.Count];
|
||||
long sentTotal = 0;
|
||||
var next = -1;
|
||||
|
||||
using var handle = File.OpenHandle(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, FileOptions.Asynchronous);
|
||||
using var failed = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
|
||||
async Task Worker()
|
||||
{
|
||||
int i;
|
||||
while ((i = Interlocked.Increment(ref next)) < partUrls.Count && !failed.IsCancellationRequested)
|
||||
{
|
||||
var offset = i * partSize;
|
||||
var len = (int)Math.Min(partSize, size - offset);
|
||||
var buffer = new byte[len];
|
||||
var read = 0;
|
||||
while (read < len) read += await RandomAccess.ReadAsync(handle, buffer.AsMemory(read), offset + read, failed.Token);
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var part = i;
|
||||
// Progress restarts from zero for a retried part.
|
||||
Interlocked.Add(ref sentTotal, -Interlocked.Exchange(ref sentPerPart[part], 0));
|
||||
var content = new ProgressStreamContent(new MemoryStream(buffer, writable: false), sent =>
|
||||
{
|
||||
var delta = sent - Interlocked.Exchange(ref sentPerPart[part], sent);
|
||||
onProgress?.Invoke(Interlocked.Add(ref sentTotal, delta));
|
||||
});
|
||||
try
|
||||
{
|
||||
var res = await _upload.PutAsync(partUrls[i], content, failed.Token);
|
||||
var etag = res.Headers.ETag?.Tag
|
||||
?? (res.Headers.TryGetValues("ETag", out var v) ? v.FirstOrDefault() : null);
|
||||
if (res.IsSuccessStatusCode && etag is not null)
|
||||
{
|
||||
etags[i] = etag;
|
||||
break;
|
||||
}
|
||||
_log.LogWarning("Part {Part}/{Count} of {Kind} for job {JobId} failed: {Status} (attempt {N}/{Max})",
|
||||
i + 1, partUrls.Count, kind, jobId, (int)res.StatusCode, attempt, PartAttempts);
|
||||
}
|
||||
catch (Exception ex) when (!failed.IsCancellationRequested)
|
||||
{
|
||||
_log.LogWarning("Part {Part}/{Count} of {Kind} for job {JobId} failed: {Error} (attempt {N}/{Max})",
|
||||
i + 1, partUrls.Count, kind, jobId, ex.Message, attempt, PartAttempts);
|
||||
}
|
||||
if (attempt >= PartAttempts)
|
||||
{
|
||||
failed.Cancel(); // no point finishing the other parts
|
||||
return;
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(5 * attempt), failed.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var concurrency = Math.Clamp(_options.UploadConcurrency, 1, 64);
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(Enumerable.Range(0, Math.Min(concurrency, partUrls.Count)).Select(_ => Task.Run(Worker)));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// a part gave up, or shutdown — either way the upload is aborted below
|
||||
}
|
||||
|
||||
var complete = !failed.IsCancellationRequested && etags.All(e => e is not null);
|
||||
if (complete)
|
||||
{
|
||||
var res = await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-complete", new
|
||||
{
|
||||
machineId,
|
||||
key = presign.Key,
|
||||
uploadId = presign.UploadId,
|
||||
parts = etags.Select((etag, i) => new { partNumber = i + 1, etag }),
|
||||
}, Json, ct);
|
||||
if (res.IsSuccessStatusCode) return true;
|
||||
_log.LogWarning("Completing multipart {Kind} for job {JobId} failed: {Status} {Body}",
|
||||
kind, jobId, (int)res.StatusCode, await res.Content.ReadAsStringAsync(ct));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _http.PostAsJsonAsync($"api/ext/render/jobs/{jobId}/artifact-complete", new
|
||||
{
|
||||
machineId,
|
||||
key = presign.Key,
|
||||
uploadId = presign.UploadId,
|
||||
abort = true,
|
||||
}, Json, CancellationToken.None);
|
||||
}
|
||||
catch { /* parts expire on their own */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<string?> UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) =>
|
||||
UploadArtifactAsync(jobId, machineId, "log", logFilePath, "text/plain", ct);
|
||||
|
||||
|
||||
@@ -8,18 +8,36 @@ namespace VFXReviewWorker;
|
||||
/// 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.
|
||||
///
|
||||
/// Blender 5.x logs through CLOG, which prefixes every line with an elapsed
|
||||
/// time and a category ("00:02.954 render | Saved: '...'") and
|
||||
/// reports errors as "ERROR ..." rather than "Error: ...". The prefix is
|
||||
/// stripped before matching, so both formats parse the same way.
|
||||
/// </summary>
|
||||
public static partial class BlenderProgressParser
|
||||
{
|
||||
[GeneratedRegex(@"^Fra:(\d+)", RegexOptions.Compiled)]
|
||||
[GeneratedRegex(@"^(?:\d+:)?\d{2}:\d{2}\.\d{3}\s+\S+\s*\| ?", RegexOptions.Compiled)]
|
||||
private static partial Regex LogPrefix();
|
||||
|
||||
[GeneratedRegex(@"^Fra:\s*(\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)]
|
||||
// Errors only. Warnings are not failures, and add-ons print them on every
|
||||
// launch ("Warning: add-on missing 'bl_info'"), so counting them made an
|
||||
// add-on's complaint the reported reason for a failed render.
|
||||
[GeneratedRegex(@"(?i:^Error: )|^(ERROR|FATAL) ", RegexOptions.Compiled)]
|
||||
private static partial Regex ErrorLine();
|
||||
|
||||
/// <summary>The line without Blender 5.x's "elapsed category | " prefix.</summary>
|
||||
private static string Message(string line)
|
||||
{
|
||||
var m = LogPrefix().Match(line);
|
||||
return m.Success ? line[m.Length..] : line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Failures that will repeat identically on another machine — a missing
|
||||
/// file, an unknown scene, an unwritable output. Retrying these burns the
|
||||
@@ -35,7 +53,9 @@ public static partial class BlenderProgressParser
|
||||
"unknown scene",
|
||||
"scene not found",
|
||||
"unable to create directory",
|
||||
"couldn't create directory",
|
||||
"cannot write file",
|
||||
"cannot save:",
|
||||
"no camera",
|
||||
"python script fail",
|
||||
"unknown argument",
|
||||
@@ -44,18 +64,18 @@ public static partial class BlenderProgressParser
|
||||
/// <summary>The frame Blender is currently working on, or null.</summary>
|
||||
public static int? ParseFrame(string line)
|
||||
{
|
||||
var m = FrameLine().Match(line);
|
||||
var m = FrameLine().Match(Message(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);
|
||||
var m = SavedLine().Match(Message(line));
|
||||
return m.Success ? m.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
public static bool IsErrorLine(string line) => ErrorLine().IsMatch(line);
|
||||
public static bool IsErrorLine(string line) => ErrorLine().IsMatch(Message(line));
|
||||
|
||||
public static bool IsNonRetryableError(string line)
|
||||
{
|
||||
|
||||
@@ -190,75 +190,93 @@ public sealed class BlenderRunner
|
||||
PumpAsync(process.StandardError, line => HandleLine(line, isStderr: true)),
|
||||
};
|
||||
|
||||
while (!process.HasExited)
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None);
|
||||
return await MonitorAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposing a Process does not end it: without this, any exception
|
||||
// in the monitor loop leaves Blender rendering on the node,
|
||||
// unsupervised, while the job is retried alongside it.
|
||||
KillTree(process);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (cancelJob.IsCancellationRequested || shutdown.IsCancellationRequested)
|
||||
async Task<RenderResult> MonitorAsync()
|
||||
{
|
||||
while (!process.HasExited)
|
||||
{
|
||||
_log.LogWarning("Cancellation requested — killing Blender tree for job {JobId}", jobId);
|
||||
KillTree(process);
|
||||
cancelledByServer = cancelJob.IsCancellationRequested;
|
||||
break;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((DateTimeOffset.UtcNow - lastProgressAt).TotalSeconds > config.StallTimeoutSeconds)
|
||||
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)
|
||||
{
|
||||
_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)",
|
||||
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);
|
||||
}
|
||||
|
||||
if ((DateTimeOffset.UtcNow - lastReportAt).TotalSeconds >= 10)
|
||||
// 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
return new RenderResult(false, 0, Retryable: false,
|
||||
firstError is not null
|
||||
? $"Blender exited cleanly but saved no frames: {firstError}"
|
||||
: "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);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -175,6 +175,11 @@ public sealed class PreviewManifest
|
||||
public string Mp4Output { get; set; } = "";
|
||||
public string? SlateScopeProp { get; set; }
|
||||
public string? SlateSubmissionProp { get; set; }
|
||||
/// <summary>
|
||||
/// Also encode the High quality MP4 from the MOV and upload the MOV itself
|
||||
/// as the version's High Res file (server "preview.highQuality").
|
||||
/// </summary>
|
||||
public bool HighQuality { get; set; }
|
||||
public SlateInfo Slate { get; set; } = new();
|
||||
|
||||
public int TotalFrames => FrameEnd - FrameStart + 1;
|
||||
@@ -299,6 +304,11 @@ public sealed class PresignResponse
|
||||
public string PresignedUrl { get; set; } = "";
|
||||
public string Key { get; set; } = "";
|
||||
public string Url { get; set; } = "";
|
||||
// Multipart (server ≥ 1.7.2 contract) — absent from older servers, which
|
||||
// only ever return PresignedUrl.
|
||||
public string? UploadId { get; set; }
|
||||
public long PartSize { get; set; }
|
||||
public List<string>? PartUrls { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExportDetailResponse
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Uploads each preview's ProRes MOV (the version's High Res download) in the
|
||||
/// background, after the job has already been finalized.
|
||||
///
|
||||
/// The MOV is by far the largest upload — hundreds of MB — and on a slow or
|
||||
/// flaky uplink it held every preview job open for 10+ minutes, during which
|
||||
/// the job looked stuck in the app and a worker restart threw the whole
|
||||
/// preview away. Now the job completes as soon as the review MP4s are up, the
|
||||
/// worker moves on to the next job, and the MOV follows; an "attach-highres"
|
||||
/// report (through the ordered spooler, so always after its finalize) attaches
|
||||
/// it to the version.
|
||||
///
|
||||
/// Pending uploads are files in highres-queue/, so a restart resumes them. The
|
||||
/// MOV itself lives on the SAN beside the EXRs, never in scratch.
|
||||
/// </summary>
|
||||
public sealed class HighResUploader
|
||||
{
|
||||
private const int MaxAttempts = 5;
|
||||
|
||||
private readonly ApiClient _api;
|
||||
private readonly ReportSpooler _spooler;
|
||||
private readonly ILogger<HighResUploader> _log;
|
||||
private readonly string _dir;
|
||||
private readonly SemaphoreSlim _wake = new(0);
|
||||
|
||||
public HighResUploader(ApiClient api, ReportSpooler spooler, ILogger<HighResUploader> log)
|
||||
{
|
||||
_api = api;
|
||||
_spooler = spooler;
|
||||
_log = log;
|
||||
_dir = Path.Combine(WorkerOptions.DataDir, "highres-queue");
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
private sealed class Pending
|
||||
{
|
||||
public string JobId { get; set; } = "";
|
||||
public string MachineId { get; set; } = "";
|
||||
public string MovPath { get; set; } = "";
|
||||
public int Attempts { get; set; }
|
||||
}
|
||||
|
||||
public void Enqueue(string jobId, string machineId, string movPath)
|
||||
{
|
||||
var item = new Pending { JobId = jobId, MachineId = machineId, MovPath = movPath };
|
||||
var name = $"{DateTimeOffset.UtcNow.UtcTicks:D20}_{jobId}.json";
|
||||
var tmp = Path.Combine(_dir, name + ".tmp");
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(item, WorkerOptions.JsonOpts));
|
||||
File.Move(tmp, Path.Combine(_dir, name));
|
||||
_log.LogInformation("ProRes for job {JobId} queued for background upload ({MB:F0} MB)",
|
||||
jobId, File.Exists(movPath) ? new FileInfo(movPath).Length / 1048576.0 : 0);
|
||||
_wake.Release();
|
||||
}
|
||||
|
||||
/// <summary>Long-running loop; started once by the worker service.</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = TimeSpan.FromSeconds(30);
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var next = Directory.EnumerateFiles(_dir, "*.json").OrderBy(f => f, StringComparer.Ordinal).FirstOrDefault();
|
||||
if (next is null)
|
||||
{
|
||||
try { await _wake.WaitAsync(TimeSpan.FromSeconds(30), ct); } catch (OperationCanceledException) { break; }
|
||||
continue;
|
||||
}
|
||||
|
||||
Pending? item;
|
||||
try { item = JsonSerializer.Deserialize<Pending>(File.ReadAllText(next), WorkerOptions.JsonOpts); }
|
||||
catch { item = null; }
|
||||
if (item is null || string.IsNullOrEmpty(item.JobId))
|
||||
{
|
||||
_log.LogWarning("Discarding unreadable ProRes queue file {File}", next);
|
||||
TryDelete(next);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(item.MovPath))
|
||||
{
|
||||
_log.LogWarning("ProRes for job {JobId} is gone ({Path}) — not uploading", item.JobId, item.MovPath);
|
||||
Report(item, null, $"MOV not found at {item.MovPath}");
|
||||
TryDelete(next);
|
||||
continue;
|
||||
}
|
||||
|
||||
string? key;
|
||||
try
|
||||
{
|
||||
key = await _api.UploadArtifactAsync(item.JobId, item.MachineId, "highres", item.MovPath, "video/quicktime", ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
_log.LogInformation("Worker stopping — ProRes upload for job {JobId} resumes on next start", item.JobId);
|
||||
break;
|
||||
}
|
||||
|
||||
if (key is not null)
|
||||
{
|
||||
Report(item, key, null);
|
||||
TryDelete(next);
|
||||
backoff = TimeSpan.FromSeconds(30);
|
||||
continue;
|
||||
}
|
||||
|
||||
item.Attempts++;
|
||||
if (item.Attempts >= MaxAttempts)
|
||||
{
|
||||
_log.LogWarning("ProRes upload for job {JobId} failed {N} times — giving up", item.JobId, item.Attempts);
|
||||
Report(item, null, $"upload failed {item.Attempts} times (see the worker log)");
|
||||
TryDelete(next);
|
||||
continue;
|
||||
}
|
||||
|
||||
_log.LogWarning("ProRes upload for job {JobId} failed (attempt {N}/{Max}); retrying in {Sec}s",
|
||||
item.JobId, item.Attempts, MaxAttempts, backoff.TotalSeconds);
|
||||
try { File.WriteAllText(next, JsonSerializer.Serialize(item, WorkerOptions.JsonOpts)); } catch { /* retried anyway */ }
|
||||
try { await Task.Delay(backoff, ct); } catch (OperationCanceledException) { break; }
|
||||
backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 600));
|
||||
}
|
||||
}
|
||||
|
||||
private void Report(Pending item, string? key, string? error)
|
||||
{
|
||||
_spooler.Enqueue("attach-highres", item.JobId, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = item.MachineId,
|
||||
["highResKey"] = key,
|
||||
["highResFileName"] = key is null ? null : Path.GetFileName(item.MovPath),
|
||||
["highResSize"] = key is null ? null : new FileInfo(item.MovPath).Length,
|
||||
["error"] = error,
|
||||
});
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { File.Delete(path); } catch { /* next pass */ }
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,14 @@ public sealed class JobExecutor
|
||||
private readonly DeliveryStage _delivery;
|
||||
private readonly BlenderRunner _blender;
|
||||
private readonly ReportSpooler _spooler;
|
||||
private readonly HighResUploader _highRes;
|
||||
private readonly ILogger<JobExecutor> _log;
|
||||
|
||||
public JobExecutor(WorkerOptions options, ApiClient api, AerenderRunner runner, PreviewStage preview,
|
||||
ValidationStage validation, DeliveryStage delivery, BlenderRunner blender, ReportSpooler spooler, ILogger<JobExecutor> log)
|
||||
ValidationStage validation, DeliveryStage delivery, BlenderRunner blender, ReportSpooler spooler,
|
||||
HighResUploader highRes, ILogger<JobExecutor> log)
|
||||
{
|
||||
_highRes = highRes;
|
||||
_options = options;
|
||||
_api = api;
|
||||
_runner = runner;
|
||||
@@ -76,12 +79,15 @@ public sealed class JobExecutor
|
||||
{
|
||||
long totalBytes = 0;
|
||||
int fileCount = 0;
|
||||
var prefix = AerenderRunner.OutputPatternPrefix(manifest.OutputPattern);
|
||||
// Prefix and extension both: the preview MOV/MP4 for this
|
||||
// version share the basename and live in this same folder, and
|
||||
// counting them as frames would inflate the EXR stats.
|
||||
var pattern = SequencePattern.FromOutputPattern(manifest.OutputPattern);
|
||||
try
|
||||
{
|
||||
foreach (var f in Directory.EnumerateFiles(outputDirLocal))
|
||||
{
|
||||
if (!Path.GetFileName(f).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!pattern.MatchesOutputFile(Path.GetFileName(f))) continue;
|
||||
fileCount++;
|
||||
totalBytes += new FileInfo(f).Length;
|
||||
}
|
||||
@@ -94,8 +100,8 @@ public sealed class JobExecutor
|
||||
// range, template mismatch, silently swallowed error).
|
||||
// Until Phase 3 validation exists, an empty output dir must
|
||||
// never become READY_FOR_QC.
|
||||
_log.LogError("Job {JobId}: aerender exited 0 but no \"{Prefix}*\" files exist in {Dir} — reporting failure",
|
||||
job.Id, prefix, outputDirLocal);
|
||||
_log.LogError("Job {JobId}: aerender exited 0 but no \"{Prefix}*{Ext}\" files exist in {Dir} — reporting failure",
|
||||
job.Id, pattern.Prefix, pattern.Extension, outputDirLocal);
|
||||
_spooler.Enqueue("fail", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
@@ -415,8 +421,15 @@ public sealed class JobExecutor
|
||||
return; // temp AEP is deliberately kept for debugging
|
||||
}
|
||||
|
||||
// From here on aerender is done and the app would keep showing its
|
||||
// last frame line; this reports what the worker is doing instead,
|
||||
// and renews the lease through the encode and uploads.
|
||||
await using var status = new LeaseKeepAlive(_api, job.Id, machineId, TimeSpan.FromSeconds(10), shutdown);
|
||||
status.Line($"Preview rendered in {result.Seconds:F0}s — uploading review media");
|
||||
|
||||
var previewKey = await _api.UploadArtifactAsync(
|
||||
job.Id, machineId, "preview", result.Mp4Path!, "video/mp4", shutdown);
|
||||
job.Id, machineId, "preview", result.Mp4Path!, "video/mp4", shutdown,
|
||||
status.Track("preview MP4", result.Mp4Path!));
|
||||
if (previewKey is null)
|
||||
{
|
||||
// The renders are on the SAN and fine — only the upload failed,
|
||||
@@ -430,6 +443,7 @@ public sealed class JobExecutor
|
||||
});
|
||||
return;
|
||||
}
|
||||
status.Line("Preview MP4 uploaded");
|
||||
|
||||
string? thumbKey = null;
|
||||
if (result.ThumbnailPath is not null)
|
||||
@@ -438,6 +452,30 @@ public sealed class JobExecutor
|
||||
job.Id, machineId, "thumbnail", result.ThumbnailPath, "image/jpeg", shutdown);
|
||||
}
|
||||
|
||||
// High quality MP4 + the MOV as the version's High Res download
|
||||
// (§9.6). Both optional: a failure here costs the extras, never the
|
||||
// preview, which is already safely uploaded. The MOV is uploaded in
|
||||
// the background after finalize (HighResUploader) — it is the big
|
||||
// one and used to hold the job open for many minutes.
|
||||
string? hqKey = null;
|
||||
var highResPending = false;
|
||||
if (manifest.HighQuality)
|
||||
{
|
||||
status.Line("Encoding High quality MP4");
|
||||
var hqPath = await _preview.TryMakeHighQualityAsync(job.Id, result.MovPath!, result.Mp4Path!, shutdown);
|
||||
if (hqPath is not null)
|
||||
{
|
||||
hqKey = await _api.UploadArtifactAsync(job.Id, machineId, "preview", hqPath, "video/mp4", shutdown,
|
||||
status.Track("High quality MP4", hqPath));
|
||||
if (hqKey is null) _log.LogWarning("High quality MP4 upload failed for job {JobId}", job.Id);
|
||||
status.Line(hqKey is null ? "High quality MP4 upload FAILED (see worker log)" : "High quality MP4 uploaded");
|
||||
}
|
||||
|
||||
highResPending = result.MovPath is not null && File.Exists(result.MovPath);
|
||||
if (highResPending)
|
||||
status.Line("ProRes MOV queued for background upload — completing the job now");
|
||||
}
|
||||
|
||||
_spooler.Enqueue("finalize", job.Id, new Dictionary<string, object?>
|
||||
{
|
||||
["machineId"] = machineId,
|
||||
@@ -446,6 +484,8 @@ public sealed class JobExecutor
|
||||
["deliveryMovPath"] = manifest.MovOutput,
|
||||
["previewMovKey"] = previewKey,
|
||||
["thumbnailKey"] = thumbKey,
|
||||
["hqPreviewKey"] = hqKey,
|
||||
["highResPending"] = highResPending,
|
||||
},
|
||||
["renderStats"] = new Dictionary<string, object?>
|
||||
{
|
||||
@@ -458,9 +498,16 @@ public sealed class JobExecutor
|
||||
["frameCount"] = result.Build?.FrameCount,
|
||||
["fps"] = manifest.Fps,
|
||||
["fileName"] = Path.GetFileName(result.Mp4Path!),
|
||||
// Slate length, so the review player can place the handle
|
||||
// markers exactly instead of inferring it (server ≥ 2026-09-22).
|
||||
["pictureStartSeconds"] = result.Build?.PictureStartSeconds,
|
||||
},
|
||||
});
|
||||
|
||||
// Enqueued after finalize: its attach report must reach the spooler
|
||||
// behind finalize, which creates the version it attaches to.
|
||||
if (highResPending) _highRes.Enqueue(job.Id, machineId, result.MovPath!);
|
||||
|
||||
// Only clean up once the outputs are safely reported.
|
||||
CleanScratch(job.Id, result.TempAep);
|
||||
}
|
||||
@@ -488,6 +535,12 @@ public sealed class JobExecutor
|
||||
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_run.jsx"));
|
||||
PreviewStage.TryDelete(Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_thumb.jpg"));
|
||||
if (tempAep is not null) PreviewStage.TryDelete(tempAep);
|
||||
try
|
||||
{
|
||||
var hqDir = PreviewStage.HighQualityDir(jobId);
|
||||
if (Directory.Exists(hqDir)) Directory.Delete(hqDir, recursive: true);
|
||||
}
|
||||
catch { /* scratch only */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// Renews a job's lease with progress reports while the worker does something
|
||||
/// long that reports nothing on its own — the High quality encode and the
|
||||
/// uploads can each outlast a 300 s lease, and an expired lease lets the
|
||||
/// reaper hand the job to another machine mid-upload.
|
||||
///
|
||||
/// Each report also replaces the job's log tail with what the worker is doing
|
||||
/// now ("Uploading High quality MP4: 12.0 / 34.0 MB…"). Without it the app
|
||||
/// keeps showing aerender's last frame line and a busy job looks hung.
|
||||
/// </summary>
|
||||
internal sealed class LeaseKeepAlive : IAsyncDisposable
|
||||
{
|
||||
private readonly ApiClient _api;
|
||||
private readonly string _jobId;
|
||||
private readonly string _machineId;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
private readonly SemaphoreSlim _wake = new(0);
|
||||
private readonly Task _loop;
|
||||
private readonly object _lock = new();
|
||||
private readonly List<string> _lines = new();
|
||||
private string? _current;
|
||||
|
||||
public LeaseKeepAlive(ApiClient api, string jobId, string machineId, TimeSpan interval, CancellationToken shutdown)
|
||||
{
|
||||
_api = api;
|
||||
_jobId = jobId;
|
||||
_machineId = machineId;
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(shutdown);
|
||||
var token = _cts.Token;
|
||||
_loop = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
// Best-effort by contract (ApiClient.ProgressAsync never throws
|
||||
// on transport errors); progress stays at 1.0 — the render is done.
|
||||
await _api.ProgressAsync(_jobId, _machineId, 1.0, null, null, null, Render(), token);
|
||||
await _wake.WaitAsync(interval, token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// stopped
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Adds a finished step to the status shown in the app.</summary>
|
||||
public void Line(string text)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_current = null;
|
||||
_lines.Add($"{DateTime.Now:HH:mm:ss} {text}");
|
||||
}
|
||||
_wake.Release();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Progress callback for <see cref="ApiClient.UploadArtifactAsync"/>: keeps
|
||||
/// one live line ("Uploading X: 12.0 / 34.0 MB (35%, 2.1 MB/s)") up to date.
|
||||
/// </summary>
|
||||
public Action<long> Track(string label, string filePath)
|
||||
{
|
||||
long total = File.Exists(filePath) ? new FileInfo(filePath).Length : 0;
|
||||
var sw = Stopwatch.StartNew();
|
||||
lock (_lock) _current = $"Uploading {label}: 0.0 / {Mb(total)} MB";
|
||||
_wake.Release();
|
||||
return sent =>
|
||||
{
|
||||
var pct = total > 0 ? 100.0 * sent / total : 0;
|
||||
var speed = sent / 1048576.0 / Math.Max(0.001, sw.Elapsed.TotalSeconds);
|
||||
lock (_lock) _current = $"Uploading {label}: {Mb(sent)} / {Mb(total)} MB ({pct:F0}%, {speed:F1} MB/s)";
|
||||
};
|
||||
}
|
||||
|
||||
private static string Mb(long bytes) => (bytes / 1048576.0).ToString("F1");
|
||||
|
||||
private string Render()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var l in _lines) sb.AppendLine(l);
|
||||
if (_current is not null) sb.AppendLine($"{DateTime.Now:HH:mm:ss} {_current}");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try { await _loop; } catch { /* already reported */ }
|
||||
_cts.Dispose();
|
||||
_wake.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,107 @@ public sealed class PreviewStage
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Scratch folder for a job's High quality encode.</summary>
|
||||
internal static string HighQualityDir(string jobId) =>
|
||||
Path.Combine(WorkerOptions.ScratchDir, $"job_{jobId}_hq");
|
||||
|
||||
/// <summary>
|
||||
/// High quality review MP4 (§9.6): the delivery MOV re-encoded as
|
||||
/// full-resolution, high-bitrate H.264 High — the player's "High" quality.
|
||||
/// ProRes itself only plays in Safari on a Mac, so the MOV is offered as a
|
||||
/// download and this is what streams. Never fatal: without it the version
|
||||
/// simply has Standard quality only.
|
||||
/// </summary>
|
||||
public async Task<string?> TryMakeHighQualityAsync(
|
||||
string jobId, string movPath, string mp4Path, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.FfmpegPath) || !File.Exists(_options.FfmpegPath))
|
||||
{
|
||||
_log.LogWarning("No High quality MP4 for job {JobId}: ffmpegPath is not set or missing.", jobId);
|
||||
return null;
|
||||
}
|
||||
if (!File.Exists(movPath))
|
||||
{
|
||||
_log.LogWarning("No High quality MP4 for job {JobId}: MOV not found at {Path}.", jobId, movPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var dir = HighQualityDir(jobId);
|
||||
Directory.CreateDirectory(dir);
|
||||
// Named after the review MP4 so the stored object reads as its sibling.
|
||||
var outPath = Path.Combine(dir, Path.GetFileNameWithoutExtension(mp4Path) + "_HQ.mp4");
|
||||
TryDelete(outPath);
|
||||
|
||||
var args = new List<string>
|
||||
{
|
||||
"-y", "-i", movPath,
|
||||
"-map", "0:v:0", "-map", "0:a?",
|
||||
// Visually lossless for review, capped so a busy shot can't balloon
|
||||
// past what a good connection streams. yuv420p 8-bit High profile
|
||||
// is the combination every browser decodes.
|
||||
"-c:v", "libx264", "-profile:v", "high", "-preset", "medium", "-crf", "14",
|
||||
"-maxrate", "100M", "-bufsize", "200M", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-b:a", "256k",
|
||||
// Index at the front so the player can start before it's all loaded
|
||||
"-movflags", "+faststart",
|
||||
outPath,
|
||||
};
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _options.FfmpegPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
{
|
||||
_log.LogWarning("Could not start ffmpeg for the High quality MP4 (job {JobId})", jobId);
|
||||
return null;
|
||||
}
|
||||
// Drain both pipes (see RunFfmpegThumbnailAsync — an unread pipe deadlocks ffmpeg).
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromMinutes(30));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_log.LogWarning("High quality encode for job {JobId} timed out or was cancelled — killing ffmpeg", jobId);
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
TryDelete(outPath);
|
||||
return null;
|
||||
}
|
||||
var stderr = await stderrTask;
|
||||
await stdoutTask;
|
||||
|
||||
if (process.ExitCode == 0 && File.Exists(outPath) && new FileInfo(outPath).Length > 0)
|
||||
{
|
||||
_log.LogInformation("High quality MP4 for job {JobId} encoded in {Sec:F0}s ({MB:F0} MB)",
|
||||
jobId, sw.Elapsed.TotalSeconds, new FileInfo(outPath).Length / 1e6);
|
||||
return outPath;
|
||||
}
|
||||
_log.LogWarning("High quality encode failed for job {JobId} (exit {Exit}): {Stderr}",
|
||||
jobId, process.ExitCode, Tail(stderr, 1200));
|
||||
TryDelete(outPath);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "High quality encode failed for job {JobId} (non-fatal)", jobId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How one ffmpeg attempt ended.</summary>
|
||||
private enum ThumbAttempt
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ builder.Logging.AddProvider(new FileLoggerProvider());
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton<ApiClient>();
|
||||
builder.Services.AddSingleton<ReportSpooler>();
|
||||
builder.Services.AddSingleton<HighResUploader>();
|
||||
builder.Services.AddSingleton<AerenderRunner>();
|
||||
builder.Services.AddSingleton<PreviewStage>();
|
||||
builder.Services.AddSingleton<ValidationStage>();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Net;
|
||||
|
||||
namespace VFXReviewWorker;
|
||||
|
||||
/// <summary>
|
||||
/// StreamContent that reports bytes sent, so a long upload can show progress.
|
||||
/// Length is computed from the stream, so the PUT still carries Content-Length
|
||||
/// (presigned S3 uploads reject chunked bodies).
|
||||
/// </summary>
|
||||
internal sealed class ProgressStreamContent : HttpContent
|
||||
{
|
||||
private readonly Stream _source;
|
||||
private readonly Action<long>? _onProgress;
|
||||
|
||||
public ProgressStreamContent(Stream source, Action<long>? onProgress)
|
||||
{
|
||||
_source = source;
|
||||
_onProgress = onProgress;
|
||||
}
|
||||
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||
SerializeToStreamAsync(stream, context, CancellationToken.None);
|
||||
|
||||
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
long sent = 0;
|
||||
int read;
|
||||
while ((read = await _source.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await stream.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
sent += read;
|
||||
_onProgress?.Invoke(sent);
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = _source.Length;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
every change that gets published. publish.ps1 appends the git SHA via
|
||||
SourceRevisionId, giving "1.1.0+feb1c7b".
|
||||
-->
|
||||
<Version>1.6.0</Version>
|
||||
<Version>1.7.2</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -350,6 +350,31 @@ public sealed class ValidationStage
|
||||
return;
|
||||
}
|
||||
|
||||
var deltaOf = (TimecodeReference r) =>
|
||||
renderTc.ToFrames(fps) - (r.PlateTimecode.ToFrames(fps) + (renderFirstFrame - r.PlateFirstFrame));
|
||||
|
||||
// A shot with several delivered elements: the panel names the longest
|
||||
// as the timing plate, but the comp may have been anchored to another
|
||||
// (BG01 and FG01 carry unrelated camera timecodes). When the timing
|
||||
// plate disagrees, the manifest's other delivered plates are tried
|
||||
// before failing. The comparison is unchanged — it is still a plate, so
|
||||
// a match is a real PASS and no match still FAILs against the timing plate.
|
||||
var timingPlate = reference;
|
||||
var alternatesTried = new List<object>();
|
||||
if (reference.Kind == "manifest" && deltaOf(reference) != 0)
|
||||
{
|
||||
foreach (var alternate in ResolveAlternatePlates(m, renderFirstFrame, reference))
|
||||
{
|
||||
var altDelta = deltaOf(alternate);
|
||||
alternatesTried.Add(new { platePath = alternate.PlatePath, plateTimecode = alternate.PlateTimecode.ToString(), deltaFrames = altDelta });
|
||||
if (altDelta == 0)
|
||||
{
|
||||
reference = alternate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// plate_fps — the render's rate against the plate's own.
|
||||
//
|
||||
// timecode_fps only compares the render to the manifest, and both come
|
||||
@@ -377,8 +402,8 @@ public sealed class ValidationStage
|
||||
|
||||
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 deltaFrames = deltaOf(reference);
|
||||
var matchedAlternate = !ReferenceEquals(reference, timingPlate);
|
||||
var details = new
|
||||
{
|
||||
reference = reference.Kind,
|
||||
@@ -387,10 +412,14 @@ public sealed class ValidationStage
|
||||
plateFirstFrame = reference.PlateFirstFrame,
|
||||
renderFirstFrame,
|
||||
deltaFrames,
|
||||
timingPlatePath = timingPlate.PlatePath,
|
||||
matchedAlternate,
|
||||
alternatesTried,
|
||||
};
|
||||
|
||||
if (deltaFrames == 0)
|
||||
{
|
||||
var timingDelta = deltaOf(timingPlate);
|
||||
// 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.
|
||||
@@ -398,7 +427,11 @@ public sealed class ValidationStage
|
||||
? 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));
|
||||
$"Matches {reference.Kind} plate {Path.GetFileName(reference.PlatePath ?? "")}" +
|
||||
(matchedAlternate
|
||||
? $" (the timing plate {Path.GetFileName(timingPlate.PlatePath ?? "")} is {(timingDelta > 0 ? "+" : "")}{timingDelta} frames off — the comp was anchored to this element instead)"
|
||||
: ""),
|
||||
details));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -409,7 +442,10 @@ public sealed class ValidationStage
|
||||
? $"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."
|
||||
: "");
|
||||
: "") +
|
||||
(alternatesTried.Count > 0
|
||||
? $" Also checked {alternatesTried.Count} other delivered plate{(alternatesTried.Count == 1 ? "" : "s")} in the manifest: none match."
|
||||
: "");
|
||||
|
||||
checks.Add(reference.Kind == "api"
|
||||
? ValidationCheck.Warn("timecode_plate_match", expected.ToString(), renderTc.ToString(),
|
||||
@@ -430,20 +466,9 @@ public sealed class ValidationStage
|
||||
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);
|
||||
var manifestPlate = ReadManifestPlate(timing, mapper, handleFrames, renderFirstFrame);
|
||||
if (manifestPlate is not null) return manifestPlate;
|
||||
_log.LogWarning("Timing plate from manifest is unreadable: {Path}", mapper.Map(timing.Path));
|
||||
}
|
||||
|
||||
// Rank 2 — derive the plate from a configured search root.
|
||||
@@ -469,6 +494,63 @@ public sealed class ValidationStage
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TimecodeReference? ReadManifestPlate(PlateRef plate, PathMapper mapper, int handleFrames, int renderFirstFrame)
|
||||
{
|
||||
var local = mapper.Map(plate.Path);
|
||||
var header = File.Exists(local) ? ExrHeader.TryRead(local) : null;
|
||||
if (header?.Timecode is null) return 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))
|
||||
?? plate.FrameStart
|
||||
?? renderFirstFrame;
|
||||
return new TimecodeReference("manifest", local, header.Timecode.Value, frame, handleFrames,
|
||||
header.FramesPerSecond);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The manifest's other delivered plates, tried when the timing plate does
|
||||
/// not match. Only real footage qualifies — under a configured plate search
|
||||
/// root or, with none configured, alongside the timing plate in its footage
|
||||
/// folder — because plates[] is every file in the comp, and a pre-render
|
||||
/// agrees with the render whether or not either is right.
|
||||
/// </summary>
|
||||
private IEnumerable<TimecodeReference> ResolveAlternatePlates(RenderManifest m, int renderFirstFrame, TimecodeReference timingPlate)
|
||||
{
|
||||
var plates = m.Source?.Plates;
|
||||
if (plates is null || plates.Count == 0 || timingPlate.PlatePath is null) yield break;
|
||||
|
||||
var mapper = new PathMapper(_options.PathMappings);
|
||||
static string AsFolder(string dir) => Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir)) + Path.DirectorySeparatorChar;
|
||||
|
||||
var deliveredRoots = _options.PlateSearchRoots.Count > 0
|
||||
? _options.PlateSearchRoots.Select(r => AsFolder(mapper.Map(r))).ToList()
|
||||
// {footage}/{shot}_{element}/file.exr or {footage}/{shot}/{element}/file.exr:
|
||||
// two levels up from the timing plate is the folder its siblings share.
|
||||
: Path.GetDirectoryName(Path.GetDirectoryName(Path.GetFullPath(timingPlate.PlatePath))) is { } footage
|
||||
? new List<string> { AsFolder(footage) }
|
||||
: new List<string>();
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(timingPlate.PlatePath) };
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plate.Path)) continue;
|
||||
var local = Path.GetFullPath(mapper.Map(plate.Path));
|
||||
if (!seen.Add(local)) continue;
|
||||
if (!deliveredRoots.Any(root => local.StartsWith(root, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
|
||||
var reference = ReadManifestPlate(plate, mapper, timingPlate.HandleFrames, renderFirstFrame);
|
||||
if (reference is null)
|
||||
{
|
||||
_log.LogWarning("Alternate plate from manifest is unreadable: {Path}", local);
|
||||
continue;
|
||||
}
|
||||
yield return reference;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -645,6 +727,16 @@ public sealed class SequencePattern
|
||||
return new SequencePattern { Prefix = prefix, Extension = ext, Padding = padding };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a filename belongs to *this* render's output: the pattern's own
|
||||
/// prefix and extension. A version folder also holds the preview MOV and
|
||||
/// MP4, which are built from the same basename and so share the prefix —
|
||||
/// only the extension separates them from the frames.
|
||||
/// </summary>
|
||||
public bool MatchesOutputFile(string fileName) =>
|
||||
fileName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& fileName.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public int? FrameNumberOf(string fileName) => FrameFromName(fileName);
|
||||
|
||||
/// <summary>`UNG_102_019_010_cmp_TT_v005.01001.exr` → 1001.</summary>
|
||||
|
||||
@@ -35,6 +35,12 @@ public sealed class WorkerOptions
|
||||
/// </summary>
|
||||
public List<string> PlateSearchRoots { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Parallel connections per large upload. From the studio each connection
|
||||
/// to object storage is capped near 0.1 MB/s, so throughput scales with
|
||||
/// this (measured: 1 → 0.13 MB/s, 8 → 0.64, 24 → 1.69).
|
||||
/// </summary>
|
||||
public int UploadConcurrency { get; set; } = 16;
|
||||
/// <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.
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class WorkerService : BackgroundService
|
||||
private readonly ApiClient _api;
|
||||
private readonly JobExecutor _executor;
|
||||
private readonly ReportSpooler _spooler;
|
||||
private readonly HighResUploader _highRes;
|
||||
private readonly ILogger<WorkerService> _log;
|
||||
|
||||
private string? _machineId;
|
||||
@@ -23,8 +24,10 @@ public sealed class WorkerService : BackgroundService
|
||||
private CancellationTokenSource? _cancelCurrentJob;
|
||||
private bool? _lastAeOpen;
|
||||
|
||||
public WorkerService(WorkerOptions options, ApiClient api, JobExecutor executor, ReportSpooler spooler, ILogger<WorkerService> log)
|
||||
public WorkerService(WorkerOptions options, ApiClient api, JobExecutor executor, ReportSpooler spooler,
|
||||
HighResUploader highRes, ILogger<WorkerService> log)
|
||||
{
|
||||
_highRes = highRes;
|
||||
_options = options;
|
||||
_api = api;
|
||||
_executor = executor;
|
||||
@@ -40,6 +43,7 @@ public sealed class WorkerService : BackgroundService
|
||||
_ = Task.Run(() => _spooler.RunAsync(stoppingToken), stoppingToken);
|
||||
|
||||
await RegisterWithRetryAsync(stoppingToken);
|
||||
_ = Task.Run(() => _highRes.RunAsync(stoppingToken), stoppingToken);
|
||||
await _api.ReportVersionAsync(_options, stoppingToken);
|
||||
await _executor.ReconcileAsync(_machineId!, stoppingToken);
|
||||
|
||||
|
||||
+42
-8
@@ -32,7 +32,7 @@ var SETTINGS_KEY = "cfg";
|
||||
// Bump on every change deployed to a workstation. The panel reports this to
|
||||
// the server on load, so Pipeline → Machines shows which version each machine
|
||||
// is actually running and flags the ones left behind.
|
||||
var PANEL_VERSION = "2.6.2";
|
||||
var PANEL_VERSION = "2.7.1";
|
||||
|
||||
(function VFXReviewConnector(thisObj) {
|
||||
var uiState = {
|
||||
@@ -45,6 +45,8 @@ var PANEL_VERSION = "2.6.2";
|
||||
pipelineStatusText: null,
|
||||
urgentCheckbox: null,
|
||||
skipPreviewCheckbox: null,
|
||||
handlesCheckbox: null,
|
||||
handleFramesField: null,
|
||||
submissionNoteField: null,
|
||||
qcList: null,
|
||||
qcNotesField: null,
|
||||
@@ -3476,6 +3478,9 @@ var PANEL_VERSION = "2.6.2";
|
||||
CONFIG.bannerImagePath = trimText(fBanner.text);
|
||||
CONFIG.artistEmail = trimText(fEmail.text);
|
||||
CONFIG.handleFrames = parseInt(fHandles.text, 10) || 8;
|
||||
if (uiState.handleFramesField) {
|
||||
uiState.handleFramesField.text = String(CONFIG.handleFrames);
|
||||
}
|
||||
CONFIG.pathMappings = trimText(fMappings.text);
|
||||
CONFIG.sequenceStart = parseInt(fSeqStart.text, 10) || 1001;
|
||||
saveConfig();
|
||||
@@ -4196,7 +4201,23 @@ var PANEL_VERSION = "2.6.2";
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// Handles the render carries past the cut. Review metadata only: the web
|
||||
// player marks these frames as "not in edit". Nothing in the comp or the
|
||||
// render changes — unticked just means the frames are all in the edit.
|
||||
function reviewHandles() {
|
||||
var frames;
|
||||
if (!uiState.handlesCheckbox || !uiState.handlesCheckbox.value) {
|
||||
return { handleHead: 0, handleTail: 0 };
|
||||
}
|
||||
frames = parseInt(uiState.handleFramesField ? uiState.handleFramesField.text : "", 10);
|
||||
if (isNaN(frames) || frames < 0) {
|
||||
frames = CONFIG.handleFrames;
|
||||
}
|
||||
return { handleHead: frames, handleTail: frames };
|
||||
}
|
||||
|
||||
function submitExport(manifest, urgent, force) {
|
||||
manifest.review = reviewHandles();
|
||||
var body = {
|
||||
manifest: manifest,
|
||||
force: force ? true : false
|
||||
@@ -4689,7 +4710,6 @@ var PANEL_VERSION = "2.6.2";
|
||||
var importRendersButton;
|
||||
var btnRow6;
|
||||
var pictureLockButton;
|
||||
var btnRow7;
|
||||
var incrementVersionButton;
|
||||
var btnRow6b;
|
||||
var setStartTCButton;
|
||||
@@ -4803,6 +4823,25 @@ var PANEL_VERSION = "2.6.2";
|
||||
"un-punched-in EXRs — where a preview rebuilt from these frames\n" +
|
||||
"would be the wrong picture.";
|
||||
|
||||
var handlesGroup = pipelinePanel.add("group");
|
||||
handlesGroup.orientation = "row";
|
||||
handlesGroup.alignChildren = ["left", "center"];
|
||||
handlesGroup.spacing = 6;
|
||||
uiState.handlesCheckbox = handlesGroup.add("checkbox", undefined, "Includes handles");
|
||||
uiState.handlesCheckbox.value = true;
|
||||
uiState.handleFramesField = handlesGroup.add("edittext", undefined, String(CONFIG.handleFrames));
|
||||
uiState.handleFramesField.characters = 3;
|
||||
handlesGroup.add("statictext", undefined, "frames each end");
|
||||
uiState.handlesCheckbox.helpTip =
|
||||
"The render runs past the cut at both ends (handles). The review\n" +
|
||||
"player marks those frames HANDLE / NOT IN EDIT so a glitch in a\n" +
|
||||
"handle isn't mistaken for a problem in the shot.\n\n" +
|
||||
"Review-only: nothing is burned in and the EXRs are unchanged.\n" +
|
||||
"Untick for a render cut exactly to the edit.";
|
||||
uiState.handlesCheckbox.onClick = function () {
|
||||
uiState.handleFramesField.enabled = uiState.handlesCheckbox.value;
|
||||
};
|
||||
|
||||
var slateNoteGroup = pipelinePanel.add("group");
|
||||
slateNoteGroup.orientation = "row";
|
||||
slateNoteGroup.alignChildren = ["fill", "top"];
|
||||
@@ -4828,6 +4867,7 @@ var PANEL_VERSION = "2.6.2";
|
||||
"Rebuild the MOV/MP4 slate and burn-ins for the delivered version.\n" +
|
||||
"Reuses the rendered EXRs and keeps the version number. Slate text is\n" +
|
||||
"re-read from the shot, so fix it on the web app's Slate tab first.";
|
||||
incrementVersionButton = pipelineRow3.add("button", undefined, "Increment Shot Version");
|
||||
|
||||
qcPanel = scrollContent.add("panel", undefined, "QC");
|
||||
qcPanel.orientation = "column";
|
||||
@@ -4934,12 +4974,6 @@ var PANEL_VERSION = "2.6.2";
|
||||
btnRow6b.spacing = 6;
|
||||
setStartTCButton = btnRow6b.add("button", undefined, "Set Start Timecode");
|
||||
|
||||
btnRow7 = buttonsPanel.add("group");
|
||||
btnRow7.orientation = "row";
|
||||
btnRow7.alignChildren = ["fill", "center"];
|
||||
btnRow7.spacing = 6;
|
||||
incrementVersionButton = btnRow7.add("button", undefined, "Increment Shot Version");
|
||||
|
||||
workspacePanel = scrollContent.add("panel", undefined, "Workspace");
|
||||
workspacePanel.orientation = "column";
|
||||
workspacePanel.alignChildren = ["fill", "top"];
|
||||
|
||||
@@ -178,7 +178,7 @@ Same applies to `footageRoot`: change `V:/_FOOTAGE/UNG` → `V:/_FOOTAGE`.
|
||||
The panel is divided into seven sections:
|
||||
|
||||
1. **Shot Builder** — Episode/shot selection and comp creation
|
||||
2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews
|
||||
2. **Render Pipeline** — Queue Export, status polling, retry, regenerate previews, increment shot version
|
||||
3. **QC** — Technical QC queue for rendered exports
|
||||
4. **Actions** — Per-shot and batch operations
|
||||
5. **Workspace** — One-time project initialisation
|
||||
|
||||
@@ -14,7 +14,7 @@ const saveAnnotationSchema = z.object({
|
||||
shapes: z.array(z.any()),
|
||||
canvasWidth: z.number(),
|
||||
canvasHeight: z.number(),
|
||||
version: z.literal("1.0"),
|
||||
version: z.enum(["1.0", "2.0"]),
|
||||
}),
|
||||
color: z.string().default("#ef4444"),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { findShot, parseBoardText } from "@/lib/board-parse";
|
||||
|
||||
// The board is studio-internal. Anyone on staff can add and tick off lines;
|
||||
// it is a shared to-do list, not an approval surface.
|
||||
async function requireStaff() {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (session.user.role === "CLIENT") throw new Error("Insufficient permissions");
|
||||
return session.user;
|
||||
}
|
||||
|
||||
async function boardContext(projectId: string) {
|
||||
const [shots, users] = await Promise.all([
|
||||
db.shot.findMany({
|
||||
where: { projectId },
|
||||
select: { id: true, shotCode: true, clientCode: true },
|
||||
}),
|
||||
db.user.findMany({
|
||||
where: { isActive: true, role: { not: "CLIENT" } },
|
||||
select: { id: true, name: true, email: true },
|
||||
}),
|
||||
]);
|
||||
return { shots, users };
|
||||
}
|
||||
|
||||
const addSchema = z.object({
|
||||
projectId: z.string().cuid(),
|
||||
input: z.string().min(1).max(20000),
|
||||
group: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
/** Adds one line, or a pasted block of lines, to a project's board. */
|
||||
export async function addBoardItems(data: z.infer<typeof addSchema>) {
|
||||
const user = await requireStaff();
|
||||
const parsed = addSchema.parse(data);
|
||||
|
||||
const items = parseBoardText(parsed.input, {
|
||||
...(await boardContext(parsed.projectId)),
|
||||
group: parsed.group,
|
||||
});
|
||||
if (items.length === 0) return { success: true, added: 0, unmatched: [] as string[] };
|
||||
|
||||
const last = await db.boardItem.findFirst({
|
||||
where: { projectId: parsed.projectId },
|
||||
orderBy: { sortOrder: "desc" },
|
||||
select: { sortOrder: true },
|
||||
});
|
||||
const base = (last?.sortOrder ?? -1) + 1;
|
||||
|
||||
await db.boardItem.createMany({
|
||||
data: items.map((item, i) => ({
|
||||
projectId: parsed.projectId,
|
||||
shotId: item.shotId,
|
||||
code: item.code,
|
||||
text: item.text,
|
||||
group: item.group,
|
||||
assigneeId: item.assigneeId,
|
||||
waitingOn: item.waitingOn,
|
||||
sortOrder: base + i,
|
||||
createdById: user.id,
|
||||
})),
|
||||
});
|
||||
|
||||
revalidatePath("/board");
|
||||
return {
|
||||
success: true,
|
||||
added: items.length,
|
||||
unmatched: items.filter((i) => i.code && !i.shotId).map((i) => i.code as string),
|
||||
};
|
||||
}
|
||||
|
||||
const updateSchema = z.object({
|
||||
text: z.string().max(5000).optional(),
|
||||
code: z.string().max(100).nullable().optional(),
|
||||
group: z.string().max(100).optional(),
|
||||
assigneeId: z.string().cuid().nullable().optional(),
|
||||
waitingOn: z.string().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function updateBoardItem(id: string, data: z.infer<typeof updateSchema>) {
|
||||
await requireStaff();
|
||||
const parsed = updateSchema.parse(data);
|
||||
const item = await db.boardItem.findUnique({ where: { id }, select: { projectId: true } });
|
||||
if (!item) throw new Error("Board item not found");
|
||||
|
||||
// Changing the code re-links the line; a code that matches nothing is kept
|
||||
// as a flag, exactly as when it was first typed.
|
||||
let link: { code: string | null; shotId: string | null } | undefined;
|
||||
if (parsed.code !== undefined) {
|
||||
const code = parsed.code?.trim() || null;
|
||||
const shot = code ? findShot(code, (await boardContext(item.projectId)).shots) : null;
|
||||
link = { code: shot?.shotCode ?? code?.toUpperCase() ?? null, shotId: shot?.id ?? null };
|
||||
}
|
||||
|
||||
await db.boardItem.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.text !== undefined && { text: parsed.text.trim() }),
|
||||
...(parsed.group !== undefined && { group: parsed.group.trim() }),
|
||||
...(parsed.assigneeId !== undefined && { assigneeId: parsed.assigneeId }),
|
||||
...(parsed.waitingOn !== undefined && { waitingOn: parsed.waitingOn?.trim() || null }),
|
||||
...link,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/board");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setBoardItemDone(id: string, done: boolean) {
|
||||
const user = await requireStaff();
|
||||
const item = await db.boardItem.update({
|
||||
where: { id },
|
||||
data: done ? { doneAt: new Date(), doneById: user.id } : { doneAt: null, doneById: null },
|
||||
select: { commentId: true },
|
||||
});
|
||||
// A line from pasted feedback is that client note: ticking it off resolves
|
||||
// the comment in the player too. (The comment may have been deleted there.)
|
||||
if (item.commentId) {
|
||||
const comment = await db.comment.updateMany({
|
||||
where: { id: item.commentId },
|
||||
data: { isResolved: done },
|
||||
});
|
||||
if (comment.count) {
|
||||
const c = await db.comment.findUnique({ where: { id: item.commentId }, select: { versionId: true } });
|
||||
if (c) revalidatePath(`/review/${c.versionId}`);
|
||||
}
|
||||
}
|
||||
revalidatePath("/board");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteBoardItem(id: string) {
|
||||
await requireStaff();
|
||||
await db.boardItem.delete({ where: { id } });
|
||||
revalidatePath("/board");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const statusSchema = z.object({
|
||||
projectId: z.string().cuid(),
|
||||
status: z.string().max(2000),
|
||||
});
|
||||
|
||||
export async function setProjectBoardStatus(data: z.infer<typeof statusSchema>) {
|
||||
await requireStaff();
|
||||
const parsed = statusSchema.parse(data);
|
||||
const status = parsed.status.trim() || null;
|
||||
await db.project.update({
|
||||
where: { id: parsed.projectId },
|
||||
data: { boardStatus: status, boardStatusAt: status ? new Date() : null },
|
||||
});
|
||||
revalidatePath("/board");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const milestoneSchema = z.object({
|
||||
projectId: z.string().cuid(),
|
||||
label: z.string().min(1).max(200),
|
||||
date: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function addMilestone(data: z.infer<typeof milestoneSchema>) {
|
||||
await requireStaff();
|
||||
const parsed = milestoneSchema.parse(data);
|
||||
const date = new Date(parsed.date);
|
||||
if (isNaN(date.getTime())) throw new Error("Invalid date");
|
||||
await db.projectMilestone.create({
|
||||
data: { projectId: parsed.projectId, label: parsed.label.trim(), date },
|
||||
});
|
||||
revalidatePath("/board");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setMilestoneDone(id: string, done: boolean) {
|
||||
await requireStaff();
|
||||
await db.projectMilestone.update({
|
||||
where: { id },
|
||||
data: { doneAt: done ? new Date() : null },
|
||||
});
|
||||
revalidatePath("/board");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteMilestone(id: string) {
|
||||
await requireStaff();
|
||||
await db.projectMilestone.delete({ where: { id } });
|
||||
revalidatePath("/board");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getUnreadCommentsForShot,
|
||||
getUnreadCommentSummaries,
|
||||
markShotCommentsRead as markRead,
|
||||
} from "@/lib/comment-reads";
|
||||
|
||||
export async function listUnreadShotComments(shotId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
return getUnreadCommentsForShot(session.user.id, z.string().min(1).parse(shotId));
|
||||
}
|
||||
|
||||
const markSchema = z.object({
|
||||
shotId: z.string().min(1),
|
||||
upTo: z.string().datetime(),
|
||||
});
|
||||
|
||||
export async function markShotCommentsRead(shotId: string, upTo: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
const parsed = markSchema.parse({ shotId, upTo });
|
||||
await markRead(session.user.id, parsed.shotId, new Date(parsed.upTo));
|
||||
revalidatePath("/shot-status");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Mark every flagged shot in a project read, up to what each one had when checked. */
|
||||
export async function markProjectCommentsRead(projectId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
const summaries = await getUnreadCommentSummaries(session.user.id, z.string().min(1).parse(projectId));
|
||||
for (const [shotId, s] of Object.entries(summaries)) {
|
||||
await markRead(session.user.id, shotId, new Date(s.latestAt));
|
||||
}
|
||||
revalidatePath("/shot-status");
|
||||
return { success: true, count: Object.keys(summaries).length };
|
||||
}
|
||||
+13
-11
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
|
||||
import { notifyCommentsAdded, notifyCommentReply } from "@/lib/notifications";
|
||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||
import { createDisplayEvent } from "@/lib/display-events";
|
||||
|
||||
@@ -36,7 +36,6 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the shot artist if commenter is not the artist
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: parsed.versionId },
|
||||
include: {
|
||||
@@ -56,16 +55,19 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
|
||||
version?.task?.title ??
|
||||
"Task";
|
||||
|
||||
if (version?.shot?.artistId && version.shot.artistId !== session.user.id) {
|
||||
const user = await db.user.findUnique({ where: { id: session.user.id } });
|
||||
await notifyFeedbackAdded({
|
||||
artistId: version.shot.artistId,
|
||||
shotCode: version.shot.shotCode,
|
||||
frameNumber: parsed.frameNumber,
|
||||
// Staff and the shot's artist — never the commenter. A failed notification
|
||||
// must not fail a comment that is already saved.
|
||||
const user = await db.user.findUnique({ where: { id: session.user.id }, select: { name: true } });
|
||||
await notifyCommentsAdded({
|
||||
notes: [{
|
||||
versionId: parsed.versionId,
|
||||
authorName: user?.name ?? session.user.email ?? "Someone",
|
||||
});
|
||||
}
|
||||
shotCode,
|
||||
frameNumber: parsed.frameNumber,
|
||||
artistIds: [version?.shot?.artistId, version?.task?.assignedArtistId],
|
||||
}],
|
||||
authorId: session.user.id,
|
||||
authorName: user?.name ?? session.user.email ?? "Someone",
|
||||
}).catch((err) => console.error("[comments] notify failed", err));
|
||||
|
||||
// Slack notification
|
||||
if (slackWebhook) {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse";
|
||||
import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse";
|
||||
import { updateTaskStatus } from "@/actions/tasks";
|
||||
import { notifyCommentsAdded, type NewCommentNote } from "@/lib/notifications";
|
||||
|
||||
/** Comment.source for notes pasted from a client message */
|
||||
const SOURCE = "WHATSAPP";
|
||||
|
||||
async function requireStaff() {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (session.user.role === "CLIENT") throw new Error("Insufficient permissions");
|
||||
return session.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shot a pasted code means: exact code or client alias first, then a code
|
||||
* the client shortened by dropping the show prefix ("113_018_040" for
|
||||
* "UNG_113_018_040") when exactly one shot ends with it.
|
||||
*/
|
||||
function resolveShot<T extends BoardShot>(code: string, shots: T[]): T | null {
|
||||
const exact = findShot(code, shots) as T | null;
|
||||
if (exact) return exact;
|
||||
const tail = `_${code.toUpperCase()}`;
|
||||
const hits = shots.filter((s) => s.shotCode.toUpperCase().endsWith(tail));
|
||||
return hits.length === 1 ? hits[0] : null;
|
||||
}
|
||||
|
||||
export interface FeedbackVersionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
sent: boolean;
|
||||
sharedAt: string | null;
|
||||
taskId: string | null;
|
||||
taskStatus: string | null;
|
||||
/** The note's quoted frame converted for this version's media; null = no frame quoted */
|
||||
playerFrame: number | null;
|
||||
fromComp: boolean;
|
||||
}
|
||||
|
||||
export interface FeedbackPreviewNote {
|
||||
key: string;
|
||||
code: string;
|
||||
text: string;
|
||||
quotedFrame: number | null;
|
||||
shot: { id: string; shotCode: string } | null;
|
||||
suggestion: string | null;
|
||||
versions: FeedbackVersionOption[];
|
||||
defaultVersionId: string | null;
|
||||
}
|
||||
|
||||
export interface FeedbackPreview {
|
||||
rounds: { title: string; dueText: string | null; notes: FeedbackPreviewNote[] }[];
|
||||
ignored: string[];
|
||||
}
|
||||
|
||||
export async function previewFeedback(projectId: string, input: string): Promise<FeedbackPreview> {
|
||||
await requireStaff();
|
||||
z.string().cuid().parse(projectId);
|
||||
const parsed = parseFeedback(z.string().max(50000).parse(input));
|
||||
|
||||
const shots = await db.shot.findMany({
|
||||
where: { projectId },
|
||||
select: { id: true, shotCode: true, clientCode: true, frameStart: true },
|
||||
});
|
||||
|
||||
const matched = new Map<string, (typeof shots)[number] | null>();
|
||||
for (const r of parsed.rounds) for (const n of r.notes) {
|
||||
const code = n.codes[0];
|
||||
if (!matched.has(code)) matched.set(code, resolveShot(code, shots));
|
||||
}
|
||||
const shotIds = [...new Set([...matched.values()].filter(Boolean).map((s) => s!.id))];
|
||||
|
||||
// Recent versions per shot, whether hung off the shot or its task
|
||||
const versions = shotIds.length
|
||||
? await db.version.findMany({
|
||||
where: { OR: [{ shotId: { in: shotIds } }, { task: { shotId: { in: shotIds } } }] },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
versionNumber: true,
|
||||
shotId: true,
|
||||
isClientVisible: true,
|
||||
sharedAt: true,
|
||||
frameCount: true,
|
||||
pictureStartFrame: true,
|
||||
task: { select: { id: true, status: true, shotId: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const byShot = new Map<string, typeof versions>();
|
||||
for (const v of versions) {
|
||||
const sid = v.shotId ?? v.task?.shotId;
|
||||
if (!sid) continue;
|
||||
const list = byShot.get(sid) ?? [];
|
||||
if (list.length < 6) list.push(v);
|
||||
byShot.set(sid, list);
|
||||
}
|
||||
|
||||
let k = 0;
|
||||
return {
|
||||
ignored: parsed.ignored,
|
||||
rounds: parsed.rounds.map((r) => ({
|
||||
title: r.title,
|
||||
dueText: r.dueText,
|
||||
notes: r.notes.map((n) => {
|
||||
const code = n.codes[0];
|
||||
const shot = matched.get(code) ?? null;
|
||||
const options: FeedbackVersionOption[] = (shot ? byShot.get(shot.id) ?? [] : []).map((v) => {
|
||||
const conv = n.frame != null
|
||||
? toPlayerFrame(n.frame, {
|
||||
shotFrameStart: shot!.frameStart,
|
||||
pictureStart: slateFrames(v),
|
||||
frameCount: v.frameCount,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
id: v.id,
|
||||
label: `v${String(v.versionNumber).padStart(3, "0")}`,
|
||||
sent: v.isClientVisible,
|
||||
sharedAt: v.sharedAt?.toISOString() ?? null,
|
||||
taskId: v.task?.id ?? null,
|
||||
taskStatus: v.task?.status ?? null,
|
||||
playerFrame: conv?.frame ?? null,
|
||||
fromComp: conv?.fromComp ?? false,
|
||||
};
|
||||
});
|
||||
// The client is commenting on what they were sent
|
||||
const def = options.find((o) => o.sent) ?? options[0] ?? null;
|
||||
return {
|
||||
key: `n${k++}`,
|
||||
code: shot?.shotCode ?? code,
|
||||
text: n.text,
|
||||
quotedFrame: n.frame,
|
||||
shot: shot ? { id: shot.id, shotCode: shot.shotCode } : null,
|
||||
suggestion: shot ? null : suggestShot(code, shots)?.shotCode ?? null,
|
||||
versions: options,
|
||||
defaultVersionId: def?.id ?? null,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const commitSchema = z.object({
|
||||
projectId: z.string().cuid(),
|
||||
rounds: z.array(z.object({
|
||||
title: z.string().max(200),
|
||||
dueAt: z.string().datetime().nullable(),
|
||||
notes: z.array(z.object({
|
||||
code: z.string().min(1).max(100),
|
||||
shotId: z.string().cuid().nullable(),
|
||||
text: z.string().min(1).max(5000),
|
||||
versionId: z.string().cuid().nullable(),
|
||||
frame: z.number().int().min(0).nullable(),
|
||||
setChanges: z.boolean(),
|
||||
})).min(1),
|
||||
})).min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Saves pasted client feedback: one FeedbackRound per round, each note as a
|
||||
* player comment (tagged as client feedback) and a board line, and — where
|
||||
* ticked — the version's task moved to Changes.
|
||||
*/
|
||||
export async function commitFeedback(data: z.infer<typeof commitSchema>) {
|
||||
const user = await requireStaff();
|
||||
const parsed = commitSchema.parse(data);
|
||||
|
||||
const notes = parsed.rounds.flatMap((r) => r.notes);
|
||||
const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])];
|
||||
const versions = await db.version.findMany({
|
||||
where: { id: { in: versionIds } },
|
||||
select: {
|
||||
id: true,
|
||||
fps: true,
|
||||
shotId: true,
|
||||
task: { select: { id: true, shotId: true, status: true, projectId: true, assignedArtistId: true, shot: { select: { shotCode: true } } } },
|
||||
shot: { select: { projectId: true, shotCode: true, artistId: true } },
|
||||
},
|
||||
});
|
||||
const vById = new Map(versions.map((v) => [v.id, v]));
|
||||
for (const n of notes) {
|
||||
if (!n.versionId) continue;
|
||||
const v = vById.get(n.versionId);
|
||||
const projectOk = v && (v.shot?.projectId ?? v.task?.projectId) === parsed.projectId;
|
||||
const shotOk = v && (v.shotId ?? v.task?.shotId) === n.shotId;
|
||||
if (!projectOk || !shotOk) throw new Error(`Version does not belong to ${n.code}`);
|
||||
}
|
||||
|
||||
const last = await db.boardItem.findFirst({
|
||||
where: { projectId: parsed.projectId },
|
||||
orderBy: { sortOrder: "desc" },
|
||||
select: { sortOrder: true },
|
||||
});
|
||||
let sort = (last?.sortOrder ?? -1) + 1;
|
||||
|
||||
let comments = 0;
|
||||
const saved: NewCommentNote[] = [];
|
||||
await db.$transaction(async (tx) => {
|
||||
for (const r of parsed.rounds) {
|
||||
const round = await tx.feedbackRound.create({
|
||||
data: {
|
||||
projectId: parsed.projectId,
|
||||
title: r.title.trim() || "Client feedback",
|
||||
dueAt: r.dueAt ? new Date(r.dueAt) : null,
|
||||
createdById: user.id,
|
||||
},
|
||||
});
|
||||
for (const n of r.notes) {
|
||||
const v = n.versionId ? vById.get(n.versionId)! : null;
|
||||
const frame = n.frame ?? 0;
|
||||
const comment = v
|
||||
? await tx.comment.create({
|
||||
data: {
|
||||
versionId: v.id,
|
||||
authorId: user.id,
|
||||
frameNumber: frame,
|
||||
timestamp: frame / v.fps,
|
||||
text: n.text.trim(),
|
||||
source: SOURCE,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (comment && v) {
|
||||
comments++;
|
||||
saved.push({
|
||||
versionId: v.id,
|
||||
shotCode: v.shot?.shotCode ?? v.task?.shot?.shotCode ?? n.code.toUpperCase(),
|
||||
frameNumber: frame,
|
||||
artistIds: [v.shot?.artistId, v.task?.assignedArtistId],
|
||||
});
|
||||
}
|
||||
await tx.boardItem.create({
|
||||
data: {
|
||||
projectId: parsed.projectId,
|
||||
shotId: n.shotId,
|
||||
code: n.code.toUpperCase(),
|
||||
text: n.text.trim(),
|
||||
roundId: round.id,
|
||||
commentId: comment?.id ?? null,
|
||||
sortOrder: sort++,
|
||||
createdById: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Outside the transaction: updateTaskStatus notifies the artist and
|
||||
// recalculates shot status, exactly as moving the task by hand does.
|
||||
const tasks = new Set<string>();
|
||||
for (const n of notes) {
|
||||
const task = n.versionId ? vById.get(n.versionId)?.task : null;
|
||||
if (n.setChanges && task && task.status !== "CHANGES") tasks.add(task.id);
|
||||
}
|
||||
for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES");
|
||||
|
||||
// Only after the transaction commits — a rolled-back paste notifies no one.
|
||||
await notifyCommentsAdded({
|
||||
notes: saved,
|
||||
authorId: user.id,
|
||||
authorName: user.name ?? user.email ?? "Someone",
|
||||
origin: "whatsapp",
|
||||
}).catch((err) => console.error("[feedback] notify failed", err));
|
||||
|
||||
for (const id of versionIds) revalidatePath(`/review/${id}`);
|
||||
revalidatePath("/board");
|
||||
return { success: true, notes: notes.length, comments, tasksChanged: tasks.size };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
|
||||
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
|
||||
import { countShotsOnOpenRequests } from "@/lib/render-pipeline/delivery-requests";
|
||||
|
||||
const createShotSchema = z.object({
|
||||
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
|
||||
@@ -652,6 +653,150 @@ export async function bulkUpdateDueDates(
|
||||
return { success: true, updated: shots.length };
|
||||
}
|
||||
|
||||
// ── Bulk Assign Artist ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Point a selection of shots at one artist, or clear the assignment when
|
||||
* `artistId` is null. Mirrors the single-shot artist field in updateShot —
|
||||
* the assignment is a plain column write with no notification, so reassigning
|
||||
* a batch during planning stays quiet.
|
||||
*/
|
||||
export async function bulkAssignArtist(shotIds: string[], artistId: 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 ids = z.array(z.string().cuid()).parse(shotIds);
|
||||
if (!ids.length) return { success: true, updated: 0, artistName: null as string | null };
|
||||
|
||||
let artistName: string | null = null;
|
||||
if (artistId) {
|
||||
const artist = await db.user.findUnique({
|
||||
where: { id: z.string().cuid().parse(artistId) },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true },
|
||||
});
|
||||
// A deactivated user or a client account must never end up owning a shot,
|
||||
// so this is checked here rather than trusting whatever the picker sent.
|
||||
if (!artist || !artist.isActive || artist.role === "CLIENT") {
|
||||
throw new Error("That user cannot be assigned shots");
|
||||
}
|
||||
artistName = artist.name ?? artist.email;
|
||||
}
|
||||
|
||||
const shots = await db.shot.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
if (shots.length !== ids.length) throw new Error("Some shots not found");
|
||||
|
||||
await db.shot.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { artistId },
|
||||
});
|
||||
|
||||
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
|
||||
revalidatePath(`/projects/${pid}`);
|
||||
}
|
||||
revalidatePath(`/shot-status`);
|
||||
|
||||
return { success: true, updated: shots.length, artistName };
|
||||
}
|
||||
|
||||
// ── Bulk Manual Delivery ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record that a selection of shots was delivered outside the render pipeline
|
||||
* (by hand, before packages existed), or clear that record when
|
||||
* `deliveredOn` is null. The delivery countdown counts these alongside
|
||||
* DELIVERED EXR packages (lib/dashboard/ops.ts).
|
||||
*
|
||||
* `deliveredOn` is the day the frames went out ("yyyy-MM-dd"), stored at
|
||||
* noon UTC so the calendar day survives any timezone it is shown in.
|
||||
*/
|
||||
export async function bulkSetManualDelivery(
|
||||
shotIds: string[],
|
||||
deliveredOn: string | null,
|
||||
note?: string,
|
||||
/** Where it was uploaded and who was sent the link. */
|
||||
evidence?: { uploadUrl?: string; sharedWith?: 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 ids = z.array(z.string().cuid()).parse(shotIds);
|
||||
if (!ids.length) return { success: true as const, updated: 0 };
|
||||
|
||||
let deliveredAt: Date | null = null;
|
||||
if (deliveredOn) {
|
||||
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).parse(deliveredOn);
|
||||
deliveredAt = new Date(`${day}T12:00:00.000Z`);
|
||||
if (Number.isNaN(deliveredAt.getTime())) throw new Error("Invalid delivery date");
|
||||
if (deliveredAt.getTime() > Date.now() + 24 * 60 * 60 * 1000) {
|
||||
throw new Error("A delivery date can't be in the future");
|
||||
}
|
||||
}
|
||||
|
||||
const shots = await db.shot.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
if (shots.length !== ids.length) throw new Error("Some shots not found");
|
||||
|
||||
const trimmedNote = note?.trim() || null;
|
||||
const uploadUrl = evidence?.uploadUrl?.trim() || null;
|
||||
const sharedWith = evidence?.sharedWith?.trim() || null;
|
||||
if (uploadUrl && !/^https?:\/\/\S+$/i.test(uploadUrl)) {
|
||||
return { success: false as const, error: "The upload link must be a full http(s) link" };
|
||||
}
|
||||
|
||||
// A backfill of old deliveries may have no link to give. A shot a client is
|
||||
// waiting on is different: without the link and recipient the request it
|
||||
// answers would stay open, so say so now rather than leave it unexplained.
|
||||
if (deliveredAt && (!uploadUrl || !sharedWith)) {
|
||||
const owed = await countShotsOnOpenRequests(ids);
|
||||
if (owed > 0) {
|
||||
// Returned, not thrown: a production build replaces a thrown action
|
||||
// error with a generic message, and this one has to reach the person.
|
||||
return {
|
||||
success: false as const,
|
||||
error: `${owed} of these shots ${owed === 1 ? "is" : "are"} on an open delivery request — add the upload link and who it was sent to`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await db.shot.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: deliveredAt
|
||||
? {
|
||||
manualDeliveredAt: deliveredAt,
|
||||
manualDeliveredById: session.user.id,
|
||||
manualDeliveryNote: trimmedNote,
|
||||
manualDeliveryUrl: uploadUrl,
|
||||
manualDeliverySharedWith: sharedWith,
|
||||
}
|
||||
: {
|
||||
manualDeliveredAt: null,
|
||||
manualDeliveredById: null,
|
||||
manualDeliveryNote: null,
|
||||
manualDeliveryUrl: null,
|
||||
manualDeliverySharedWith: null,
|
||||
},
|
||||
});
|
||||
|
||||
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
|
||||
revalidatePath(`/projects/${pid}`);
|
||||
}
|
||||
revalidatePath(`/shot-status`);
|
||||
revalidatePath(`/dashboard`);
|
||||
|
||||
return { success: true as const, updated: shots.length };
|
||||
}
|
||||
|
||||
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function bulkDeleteShots(shotIds: string[]) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { recalcShotStatus, clearStaleShotApproval } from "@/lib/shot-status";
|
||||
import { notifyNewVersionUploaded } from "@/lib/notifications";
|
||||
import { slackNotifyVersionUploaded } from "@/lib/slack";
|
||||
import { deleteStorageKeys, highResStorageKeys } from "@/lib/render-pipeline/high-res";
|
||||
|
||||
const createVersionSchema = z.object({
|
||||
taskId: z.string().cuid(),
|
||||
@@ -185,6 +186,8 @@ export async function deleteVersion(versionId: string) {
|
||||
id: true,
|
||||
isLatest: true,
|
||||
taskId: true,
|
||||
hqFileUrl: true,
|
||||
highResKey: true,
|
||||
task: { select: { projectId: true, shotId: true } },
|
||||
},
|
||||
});
|
||||
@@ -203,6 +206,8 @@ export async function deleteVersion(versionId: string) {
|
||||
}
|
||||
|
||||
await db.version.delete({ where: { id: versionId } });
|
||||
// Its High quality MP4 and ProRes are large and referenced by nothing else.
|
||||
await deleteStorageKeys(highResStorageKeys(version));
|
||||
|
||||
const projectId = version.task?.projectId;
|
||||
const shotId = version.task?.shotId;
|
||||
@@ -259,3 +264,35 @@ export async function getVersionComments(versionId: string) {
|
||||
orderBy: { frameNumber: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
const updateVersionHandlesSchema = z.object({
|
||||
versionId: z.string().cuid(),
|
||||
/** 0/0 = no handles (every frame is in the edit). */
|
||||
handleHead: z.number().int().min(0).max(1000),
|
||||
handleTail: z.number().int().min(0).max(1000),
|
||||
/** Slate frames before the picture; null = infer from the media. */
|
||||
pictureStartFrame: z.number().int().min(0).max(1000).nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Marks which frames of a version are handles (past the cut at each end), so
|
||||
* the review player can flag them. Review metadata only — the media and any
|
||||
* delivered files are untouched. Staff only: a client can't redefine the cut.
|
||||
*/
|
||||
export async function updateVersionHandles(data: z.infer<typeof updateVersionHandlesSchema>) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
if (session.user.role === "CLIENT") throw new Error("Forbidden");
|
||||
|
||||
const parsed = updateVersionHandlesSchema.parse(data);
|
||||
const none = parsed.handleHead === 0 && parsed.handleTail === 0;
|
||||
await db.version.update({
|
||||
where: { id: parsed.versionId },
|
||||
data: {
|
||||
handleHead: none ? null : parsed.handleHead,
|
||||
handleTail: none ? null : parsed.handleTail,
|
||||
pictureStartFrame: parsed.pictureStartFrame,
|
||||
},
|
||||
});
|
||||
revalidatePath(`/review/${parsed.versionId}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { differenceInCalendarDays, format, formatDistanceToNowStrict } from "date-fns";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Flag,
|
||||
Link2,
|
||||
MessageSquareText,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import {
|
||||
addBoardItems,
|
||||
addMilestone,
|
||||
deleteBoardItem,
|
||||
deleteMilestone,
|
||||
setBoardItemDone,
|
||||
setMilestoneDone,
|
||||
setProjectBoardStatus,
|
||||
updateBoardItem,
|
||||
} from "@/actions/board";
|
||||
import { FeedbackPasteDialog } from "./FeedbackPasteDialog";
|
||||
|
||||
type User = { id: string; name: string | null; email: string; image: string | null };
|
||||
|
||||
type BoardItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
code: string | null;
|
||||
group: string;
|
||||
waitingOn: string | null;
|
||||
round: { id: string; title: string; dueAt: string | null } | null;
|
||||
/** The player comment a pasted-feedback line was saved as */
|
||||
comment: { versionId: string; frame: number } | null;
|
||||
doneAt: string | null;
|
||||
assignee: { id: string; name: string | null; image: string | null } | null;
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
status: string;
|
||||
latest: { id: string; versionNumber: number; approvalStatus: string; isClientVisible: boolean } | null;
|
||||
} | null;
|
||||
relinkTo: string | null;
|
||||
relinkIsExact: boolean;
|
||||
};
|
||||
|
||||
export type BoardProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
boardStatus: string | null;
|
||||
boardStatusAt: string | null;
|
||||
shotTotal: number;
|
||||
shotComplete: number;
|
||||
milestones: { id: string; label: string; date: string; doneAt: string | null }[];
|
||||
items: BoardItem[];
|
||||
};
|
||||
|
||||
const SHOT_STATUS: Record<string, { label: string; color: string }> = {
|
||||
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" },
|
||||
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
|
||||
INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20" },
|
||||
READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20" },
|
||||
CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20" },
|
||||
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
|
||||
COMPLETE: { label: "Complete", color: "bg-green-500/10 text-green-400 border-green-500/20" },
|
||||
};
|
||||
|
||||
const APPROVAL: Record<string, { label: string; color: string }> = {
|
||||
PENDING_REVIEW: { label: "pending", color: "text-zinc-500" },
|
||||
APPROVED: { label: "approved", color: "text-green-400" },
|
||||
REJECTED: { label: "rejected", color: "text-red-400" },
|
||||
NEEDS_CHANGES: { label: "changes", color: "text-orange-400" },
|
||||
};
|
||||
|
||||
export function BoardClient({
|
||||
projects,
|
||||
users,
|
||||
who,
|
||||
}: {
|
||||
projects: BoardProject[];
|
||||
users: User[];
|
||||
who: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const filters = [
|
||||
{ key: null, label: "Everyone" },
|
||||
...users.map((u) => ({ key: u.id, label: u.name?.split(" ")[0] ?? u.email })),
|
||||
{ key: "none", label: "Unassigned" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-5xl">
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Board</h1>
|
||||
<p className="text-zinc-400 mt-1">{format(new Date(), "EEEE, d MMMM yyyy")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 rounded-lg border border-zinc-800 bg-zinc-900 p-1">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key ?? "all"}
|
||||
type="button"
|
||||
onClick={() => router.push(f.key ? `/board?who=${f.key}` : "/board")}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
who === f.key ? "bg-amber-500/10 text-amber-400" : "text-zinc-400 hover:text-white",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No active or on-hold projects.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{projects.map((p) => (
|
||||
<ProjectSection key={p.id} project={p} users={users} who={who} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSection({ project, users, who }: { project: BoardProject; users: User[]; who: string | null }) {
|
||||
const [showDone, setShowDone] = useState(false);
|
||||
const [pasting, setPasting] = useState(false);
|
||||
|
||||
const visible = project.items.filter((i) =>
|
||||
who === null ? true : who === "none" ? !i.assignee : i.assignee?.id === who,
|
||||
);
|
||||
const open = visible.filter((i) => !i.doneAt);
|
||||
const done = visible.filter((i) => i.doneAt);
|
||||
// Pasted feedback stays together under its round, flags and all
|
||||
const rounds: { round: NonNullable<BoardItem["round"]>; items: BoardItem[] }[] = [];
|
||||
for (const item of open) {
|
||||
if (!item.round) continue;
|
||||
let r = rounds.find((x) => x.round.id === item.round!.id);
|
||||
if (!r) rounds.push((r = { round: item.round, items: [] }));
|
||||
r.items.push(item);
|
||||
}
|
||||
rounds.sort((a, b) => (a.round.dueAt ?? "9").localeCompare(b.round.dueAt ?? "9"));
|
||||
const loose = open.filter((i) => !i.round);
|
||||
const flagged = loose.filter((i) => i.code && !i.shot);
|
||||
const waiting = loose.filter((i) => i.waitingOn && !(i.code && !i.shot));
|
||||
const rest = loose.filter((i) => !flagged.includes(i) && !waiting.includes(i));
|
||||
|
||||
// Groups keep the order they were first typed in
|
||||
const groups: { name: string; items: BoardItem[] }[] = [];
|
||||
for (const item of rest) {
|
||||
let g = groups.find((x) => x.name === item.group);
|
||||
if (!g) groups.push((g = { name: item.group, items: [] }));
|
||||
g.items.push(item);
|
||||
}
|
||||
groups.sort((a, b) => (a.name === "" ? -1 : b.name === "" ? 1 : 0));
|
||||
|
||||
const pct = project.shotTotal ? Math.round((project.shotComplete / project.shotTotal) * 100) : 0;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-zinc-800 bg-zinc-900">
|
||||
<header className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 px-4 py-3">
|
||||
<Link href={`/projects/${project.id}`} className="text-lg font-semibold text-white hover:text-amber-400">
|
||||
{project.name}
|
||||
</Link>
|
||||
<span className="text-xs text-zinc-500">{project.code}</span>
|
||||
{project.status === "ON_HOLD" && (
|
||||
<span className="rounded border border-yellow-500/20 bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
||||
On hold
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="ml-auto h-7"
|
||||
onClick={() => setPasting(true)}
|
||||
title="Paste a client's feedback message"
|
||||
>
|
||||
<MessageSquareText /> Paste feedback
|
||||
</Button>
|
||||
{project.shotTotal > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div className="h-full bg-green-500/70" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{project.shotComplete}/{project.shotTotal} shots complete
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 px-4 py-3">
|
||||
<StatusLine project={project} />
|
||||
<Milestones project={project} />
|
||||
|
||||
{rounds.map(({ round, items }) => (
|
||||
<RoundBlock key={round.id} round={round} count={items.length}>
|
||||
{items.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
|
||||
</RoundBlock>
|
||||
))}
|
||||
{flagged.length > 0 && (
|
||||
<ItemBlock title="Flags" icon={<Flag className="h-3.5 w-3.5 text-red-400" />}>
|
||||
{flagged.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
|
||||
</ItemBlock>
|
||||
)}
|
||||
{waiting.length > 0 && (
|
||||
<ItemBlock title="Waiting on" icon={<Clock className="h-3.5 w-3.5 text-amber-400" />}>
|
||||
{waiting.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
|
||||
</ItemBlock>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<ItemBlock key={g.name || "_"} title={g.name}>
|
||||
{g.items.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
|
||||
</ItemBlock>
|
||||
))}
|
||||
|
||||
<QuickAdd projectId={project.id} />
|
||||
|
||||
{done.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDone(!showDone)}
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
{showDone ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Done this week ({done.length})
|
||||
</button>
|
||||
{showDone && (
|
||||
<div className="mt-1">
|
||||
{done.map((i) => <ItemRow key={i.id} item={i} users={users} projectId={project.id} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FeedbackPasteDialog
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
open={pasting}
|
||||
onOpenChange={setPasting}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundBlock({
|
||||
round,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
round: NonNullable<BoardItem["round"]>;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const due = round.dueAt ? new Date(round.dueAt) : null;
|
||||
const late = due && due.getTime() < Date.now();
|
||||
const days = due ? differenceInCalendarDays(due, new Date()) : null;
|
||||
const when = due
|
||||
? days === 0 ? `today ${format(due, "HH:mm")}` : days === 1 ? `tomorrow ${format(due, "HH:mm")}` : format(due, "d MMM HH:mm")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1.5 text-xs uppercase tracking-wider text-zinc-500">
|
||||
<MessageSquareText className="h-3.5 w-3.5 text-indigo-400" />
|
||||
{round.title}
|
||||
{when && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-px normal-case tracking-normal",
|
||||
late ? "bg-red-500/10 text-red-300" : days !== null && days <= 1 ? "bg-amber-500/10 text-amber-300" : "text-zinc-400",
|
||||
)}
|
||||
>
|
||||
{late ? "overdue · " : "due "}{when}
|
||||
</span>
|
||||
)}
|
||||
<span className="normal-case tracking-normal text-zinc-600">{count} open</span>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemBlock({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
{title && (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-wider text-zinc-500">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="divide-y divide-zinc-800/60">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAction() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const run = (fn: () => Promise<unknown>, after?: () => void) =>
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await fn();
|
||||
after?.();
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({ title: "Couldn't save", description: (e as Error).message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
return { run, isPending };
|
||||
}
|
||||
|
||||
// ── Status line ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusLine({ project }: { project: BoardProject }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(project.boardStatus ?? "");
|
||||
const { run, isPending } = useAction();
|
||||
|
||||
const save = () =>
|
||||
run(() => setProjectBoardStatus({ projectId: project.id, status: value }), () => setEditing(false));
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); }
|
||||
if (e.key === "Escape") { setValue(project.boardStatus ?? ""); setEditing(false); }
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="Where things stand — e.g. Awaiting feedback on pick-ups"
|
||||
className="w-full resize-y rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={save} disabled={isPending}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setValue(project.boardStatus ?? ""); setEditing(false); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="group block w-full text-left"
|
||||
>
|
||||
{project.boardStatus ? (
|
||||
<p className="whitespace-pre-wrap text-sm text-zinc-200">
|
||||
{project.boardStatus}
|
||||
{project.boardStatusAt && (
|
||||
<span className="ml-2 text-xs text-zinc-600">
|
||||
{formatDistanceToNowStrict(new Date(project.boardStatusAt), { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
<Pencil className="ml-2 inline h-3 w-3 text-zinc-600 opacity-0 group-hover:opacity-100" />
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-600 group-hover:text-zinc-400">+ Add a status line</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Milestones ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Milestones({ project }: { project: BoardProject }) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
const { run, isPending } = useAction();
|
||||
|
||||
const add = () => {
|
||||
if (!label.trim() || !date) return;
|
||||
// Noon keeps the calendar day stable across the studio/server time zones
|
||||
run(() => addMilestone({ projectId: project.id, label, date: `${date}T12:00:00` }), () => {
|
||||
setLabel("");
|
||||
setDate("");
|
||||
setAdding(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{project.milestones.map((m) => <MilestoneChip key={m.id} m={m} />)}
|
||||
{adding ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && add()}
|
||||
placeholder="e.g. Final delivery"
|
||||
className="h-7 w-40 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && add()}
|
||||
className="h-7 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white [color-scheme:dark] focus:outline-none"
|
||||
/>
|
||||
<Button size="sm" className="h-7" onClick={add} disabled={isPending || !label.trim() || !date}>Add</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7" onClick={() => setAdding(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="flex h-7 items-center gap-1 rounded-md px-2 text-xs text-zinc-600 hover:bg-zinc-800 hover:text-zinc-300"
|
||||
>
|
||||
<Plus className="h-3 w-3" /> Date
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MilestoneChip({ m }: { m: BoardProject["milestones"][number] }) {
|
||||
const { run } = useAction();
|
||||
const days = differenceInCalendarDays(new Date(m.date), new Date());
|
||||
const done = !!m.doneAt;
|
||||
const late = !done && days < 0;
|
||||
const soon = !done && days >= 0 && days <= 7;
|
||||
const when = done ? "done" : days === 0 ? "today" : days === 1 ? "tomorrow" : days > 0 ? `in ${days}d` : `${-days}d late`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs",
|
||||
done ? "border-zinc-800 text-zinc-600" :
|
||||
late ? "border-red-500/30 bg-red-500/10 text-red-300" :
|
||||
soon ? "border-amber-500/30 bg-amber-500/10 text-amber-300" :
|
||||
"border-zinc-700 text-zinc-300",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={done ? "Mark not done" : "Mark done"}
|
||||
onClick={() => run(() => setMilestoneDone(m.id, !done))}
|
||||
className={cn(
|
||||
"flex h-3.5 w-3.5 items-center justify-center rounded-sm border",
|
||||
done ? "border-green-500/50 bg-green-500/20 text-green-400" : "border-current opacity-60 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{done && <Check className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
<span className={cn(done && "line-through")}>{m.label}</span>
|
||||
<span className="opacity-70">{format(new Date(m.date), "d MMM")}</span>
|
||||
<span className="font-medium">· {when}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Remove"
|
||||
onClick={() => { if (confirm(`Remove "${m.label}"?`)) run(() => deleteMilestone(m.id)); }}
|
||||
className="hidden text-zinc-500 hover:text-red-400 group-hover:block"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ItemRow({ item, users, projectId }: { item: BoardItem; users: User[]; projectId: string }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const { run, isPending } = useAction();
|
||||
const done = !!item.doneAt;
|
||||
const flagged = !!item.code && !item.shot;
|
||||
|
||||
if (editing) return <ItemEditor item={item} users={users} onClose={() => setEditing(false)} />;
|
||||
|
||||
const st = item.shot ? SHOT_STATUS[item.shot.status] : null;
|
||||
const v = item.shot?.latest;
|
||||
|
||||
return (
|
||||
<div className={cn("group flex items-start gap-2.5 py-1.5", isPending && "opacity-50")}>
|
||||
<button
|
||||
type="button"
|
||||
title={done ? "Mark not done" : "Mark done"}
|
||||
onClick={() => run(() => setBoardItemDone(item.id, !done))}
|
||||
className={cn(
|
||||
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
|
||||
done ? "border-green-500/50 bg-green-500/20 text-green-400" : "border-zinc-600 hover:border-zinc-400",
|
||||
)}
|
||||
>
|
||||
{done && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
{item.shot && (
|
||||
<Link
|
||||
href={`/projects/${projectId}/shots/${item.shot.id}`}
|
||||
className="font-mono text-xs text-amber-400 hover:underline"
|
||||
>
|
||||
{item.shot.shotCode}
|
||||
</Link>
|
||||
)}
|
||||
{flagged && (
|
||||
<span className="flex items-center gap-1 font-mono text-xs text-red-400" title="Not a shot in this project">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{item.code}
|
||||
</span>
|
||||
)}
|
||||
{st && !done && (
|
||||
<span className={cn("rounded border px-1.5 py-px text-[10px] font-medium", st.color)}>{st.label}</span>
|
||||
)}
|
||||
{v && !done && (
|
||||
<Link href={`/review/${v.id}`} className="text-[11px] text-zinc-500 hover:text-zinc-300">
|
||||
v{String(v.versionNumber).padStart(3, "0")}{" "}
|
||||
<span className={APPROVAL[v.approvalStatus]?.color}>{APPROVAL[v.approvalStatus]?.label}</span>
|
||||
{v.isClientVisible && <span className="text-indigo-400"> · sent</span>}
|
||||
</Link>
|
||||
)}
|
||||
<span className={cn("text-zinc-200", done && "text-zinc-600 line-through")}>{item.text}</span>
|
||||
{item.comment && (
|
||||
<Link
|
||||
href={`/review/${item.comment.versionId}`}
|
||||
className="text-[11px] text-indigo-400/80 hover:text-indigo-300"
|
||||
title="Open the note in the player"
|
||||
>
|
||||
note @ F{item.comment.frame}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(item.waitingOn || (flagged && !done)) && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
{item.waitingOn && (
|
||||
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300">
|
||||
<Clock className="h-3 w-3" /> Waiting on {item.waitingOn}
|
||||
</span>
|
||||
)}
|
||||
{flagged && !done && (
|
||||
item.relinkTo ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(() => updateBoardItem(item.id, { code: item.relinkTo }))}
|
||||
className="flex items-center gap-1 text-zinc-400 hover:text-amber-400"
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{item.relinkIsExact ? `${item.relinkTo} exists now. Link it` : `Did you mean ${item.relinkTo}?`}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-zinc-500">Not a shot in this project</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.assignee && (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs text-zinc-400" title={item.assignee.name ?? ""}>
|
||||
<Avatar className="h-5 w-5">
|
||||
{item.assignee.image && <AvatarImage src={item.assignee.image} />}
|
||||
<AvatarFallback className="text-[9px]">{getInitials(item.assignee.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden sm:inline">{item.assignee.name?.split(" ")[0]}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex shrink-0 gap-0.5 opacity-100 md:opacity-0 md:group-hover:opacity-100">
|
||||
<button type="button" title="Edit" onClick={() => setEditing(true)} className="rounded p-1 text-zinc-500 hover:bg-zinc-800 hover:text-white">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Delete"
|
||||
onClick={() => { if (confirm("Delete this line?")) run(() => deleteBoardItem(item.id)); }}
|
||||
className="rounded p-1 text-zinc-500 hover:bg-zinc-800 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemEditor({ item, users, onClose }: { item: BoardItem; users: User[]; onClose: () => void }) {
|
||||
const [code, setCode] = useState(item.shot?.shotCode ?? item.code ?? "");
|
||||
const [text, setText] = useState(item.text);
|
||||
const [assigneeId, setAssigneeId] = useState(item.assignee?.id ?? "");
|
||||
const [waitingOn, setWaitingOn] = useState(item.waitingOn ?? "");
|
||||
const [group, setGroup] = useState(item.group);
|
||||
const { run, isPending } = useAction();
|
||||
|
||||
const save = () =>
|
||||
run(
|
||||
() => updateBoardItem(item.id, { code, text, assigneeId: assigneeId || null, waitingOn, group }),
|
||||
onClose,
|
||||
);
|
||||
|
||||
const field = "h-8 rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="my-1 grid grid-cols-1 gap-2 rounded-lg border border-zinc-700 bg-zinc-950/50 p-2 sm:grid-cols-[10rem_1fr]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); save(); }
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
>
|
||||
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Shot code" className={cn(field, "font-mono")} />
|
||||
<input autoFocus value={text} onChange={(e) => setText(e.target.value)} placeholder="Note" className={field} />
|
||||
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className={field}>
|
||||
<option value="">Unassigned</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name ?? u.email}</option>)}
|
||||
</select>
|
||||
<input value={waitingOn} onChange={(e) => setWaitingOn(e.target.value)} placeholder="Waiting on (blank = not blocked)" className={field} />
|
||||
<input value={group} onChange={(e) => setGroup(e.target.value)} placeholder="Heading (optional)" className={field} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button size="sm" onClick={save} disabled={isPending}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAdd({ projectId }: { projectId: string }) {
|
||||
const [value, setValue] = useState("");
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const { toast } = useToast();
|
||||
const { run, isPending } = useAction();
|
||||
|
||||
// Grow with pasted blocks
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 320)}px`;
|
||||
}, [value]);
|
||||
|
||||
const submit = () => {
|
||||
if (!value.trim()) return;
|
||||
run(async () => {
|
||||
const res = await addBoardItems({ projectId, input: value });
|
||||
if (res.unmatched.length) {
|
||||
toast({
|
||||
title: `${res.unmatched.length} code${res.unmatched.length === 1 ? "" : "s"} not in this project`,
|
||||
description: `${res.unmatched.join(", ")}: flagged on the board.`,
|
||||
});
|
||||
}
|
||||
}, () => setValue(""));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||
}}
|
||||
rows={1}
|
||||
disabled={isPending}
|
||||
placeholder="Add a line, e.g. SHOT_CODE finalise (Chris) wait: client GFX, or paste a list"
|
||||
className="w-full resize-none rounded-lg border border-dashed border-zinc-700 bg-transparent px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:border-solid focus:border-amber-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { AlertTriangle, ArrowLeft, Link2, MessageSquareText } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseDue } from "@/lib/feedback-parse";
|
||||
import { commitFeedback, previewFeedback, type FeedbackPreview, type FeedbackPreviewNote } from "@/actions/feedback";
|
||||
|
||||
type NoteState = {
|
||||
include: boolean;
|
||||
text: string;
|
||||
versionId: string | null;
|
||||
/** Typed frame; null = use the selected version's converted frame */
|
||||
frame: string | null;
|
||||
setChanges: boolean;
|
||||
};
|
||||
type RoundState = { title: string; due: string; notes: Record<string, NoteState> };
|
||||
|
||||
const LOCAL_DT = "yyyy-MM-dd'T'HH:mm";
|
||||
|
||||
function initialState(preview: FeedbackPreview): RoundState[] {
|
||||
return preview.rounds.map((r) => {
|
||||
const due = parseDue(r.dueText);
|
||||
return {
|
||||
title: r.title || `Feedback ${format(new Date(), "d MMM")}`,
|
||||
due: due ? format(due, LOCAL_DT) : "",
|
||||
notes: Object.fromEntries(
|
||||
r.notes.map((n) => [
|
||||
n.key,
|
||||
{ include: true, text: n.text, versionId: n.defaultVersionId, frame: null, setChanges: true },
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function FeedbackPasteDialog({
|
||||
projectId,
|
||||
projectName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [input, setInput] = useState("");
|
||||
const [preview, setPreview] = useState<FeedbackPreview | null>(null);
|
||||
const [rounds, setRounds] = useState<RoundState[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const reset = () => {
|
||||
setInput("");
|
||||
setPreview(null);
|
||||
setRounds([]);
|
||||
};
|
||||
|
||||
const read = (text: string) =>
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const p = await previewFeedback(projectId, text);
|
||||
if (p.rounds.length === 0) {
|
||||
toast({ title: "No shot notes found", description: "Each note needs to start with a shot code.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setPreview(p);
|
||||
setRounds(initialState(p));
|
||||
} catch (e) {
|
||||
toast({ title: "Couldn't read feedback", description: (e as Error).message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
// Accepting a suggested code rewrites the paste and reads it again, so
|
||||
// everything downstream (versions, frames) comes from the real shot.
|
||||
const applySuggestion = (code: string, suggestion: string) => {
|
||||
const next = input.split(code).join(suggestion);
|
||||
setInput(next);
|
||||
read(next);
|
||||
};
|
||||
|
||||
const updateNote = (ri: number, key: string, patch: Partial<NoteState>) =>
|
||||
setRounds((rs) => rs.map((r, i) => (i === ri ? { ...r, notes: { ...r.notes, [key]: { ...r.notes[key], ...patch } } } : r)));
|
||||
|
||||
const frameFor = (n: FeedbackPreviewNote, s: NoteState): number | null => {
|
||||
if (s.frame !== null) return s.frame.trim() === "" ? null : Number(s.frame);
|
||||
return n.versions.find((v) => v.id === s.versionId)?.playerFrame ?? null;
|
||||
};
|
||||
|
||||
const included = preview
|
||||
? preview.rounds.flatMap((r, ri) => r.notes.filter((n) => rounds[ri]?.notes[n.key]?.include && rounds[ri].notes[n.key].text.trim()))
|
||||
: [];
|
||||
|
||||
const save = () => {
|
||||
if (!preview) return;
|
||||
const payload = preview.rounds
|
||||
.map((r, ri) => {
|
||||
const rs = rounds[ri];
|
||||
return {
|
||||
title: rs.title,
|
||||
dueAt: rs.due ? new Date(rs.due).toISOString() : null,
|
||||
notes: r.notes
|
||||
.filter((n) => rs.notes[n.key].include && rs.notes[n.key].text.trim())
|
||||
.map((n) => {
|
||||
const s = rs.notes[n.key];
|
||||
const frame = frameFor(n, s);
|
||||
return {
|
||||
code: n.code,
|
||||
shotId: n.shot?.id ?? null,
|
||||
text: s.text.trim(),
|
||||
versionId: n.shot ? s.versionId : null,
|
||||
frame: frame != null && Number.isFinite(frame) && frame >= 0 ? Math.floor(frame) : null,
|
||||
setChanges: !!s.versionId && s.setChanges,
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((r) => r.notes.length > 0);
|
||||
if (payload.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await commitFeedback({ projectId, rounds: payload });
|
||||
toast({
|
||||
title: `${res.notes} note${res.notes === 1 ? "" : "s"} logged`,
|
||||
description: `${res.comments} in the player · ${res.tasksChanged} task${res.tasksChanged === 1 ? "" : "s"} moved to Changes`,
|
||||
});
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({ title: "Couldn't save feedback", description: (e as Error).message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const field = "rounded-md border border-zinc-700 bg-zinc-950 px-2 text-xs text-white placeholder:text-zinc-600 focus:border-amber-500/50 focus:outline-none";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) reset(); onOpenChange(o); }}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquareText className="h-4 w-4 text-amber-400" />
|
||||
Paste client feedback · {projectName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{preview
|
||||
? "Check where each note will go. Notes are saved in the player as client feedback and added to the board."
|
||||
: "Paste the message as it arrived. Each note should start with a shot code; a line like “FEEDBACK 113 DUE Oct 7 12:00 PM” starts a round."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!preview ? (
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
rows={12}
|
||||
placeholder={"FEEDBACK 113 DUE Oct 7, 2026 12:00 PM\nUNG_113_008_040 The screen is partially peeking under the mask\nUNG_113_018_050 At frame 1018, the mask is still slipping"}
|
||||
className={cn(field, "w-full resize-y py-2 font-mono text-[13px] leading-relaxed")}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => read(input)} disabled={isPending || !input.trim()}>
|
||||
{isPending ? "Reading…" : "Read feedback"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{preview.ignored.length > 0 && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
Skipped (no shot code): {preview.ignored.map((l) => `“${l}”`).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.rounds.map((r, ri) => {
|
||||
const rs = rounds[ri];
|
||||
if (!rs) return null;
|
||||
return (
|
||||
<div key={ri} className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={rs.title}
|
||||
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, title: e.target.value } : x)))}
|
||||
className={cn(field, "h-8 w-56 text-sm font-medium")}
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">due</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={rs.due}
|
||||
onChange={(e) => setRounds((all) => all.map((x, i) => (i === ri ? { ...x, due: e.target.value } : x)))}
|
||||
className={cn(field, "h-8 [color-scheme:dark]")}
|
||||
/>
|
||||
{r.dueText && !rs.due && (
|
||||
<span className="text-xs text-amber-400">Couldn’t read “{r.dueText}”, set it by hand</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-800">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-zinc-950 text-left uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="w-8 px-2 py-2" />
|
||||
<th className="px-2 py-2">Shot</th>
|
||||
<th className="px-2 py-2">Note</th>
|
||||
<th className="px-2 py-2">Version</th>
|
||||
<th className="px-2 py-2">Frame</th>
|
||||
<th className="px-2 py-2" title="Move the task to Changes">Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{r.notes.map((n) => {
|
||||
const s = rs.notes[n.key];
|
||||
const v = n.versions.find((x) => x.id === s.versionId) ?? null;
|
||||
const frame = frameFor(n, s);
|
||||
return (
|
||||
<tr key={n.key} className={cn("align-top", !s.include && "opacity-40")}>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.include}
|
||||
onChange={(e) => updateNote(ri, n.key, { include: e.target.checked })}
|
||||
className="mt-1 accent-amber-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-2 font-mono">
|
||||
{n.shot ? (
|
||||
<span className="text-amber-400">{n.shot.shotCode}</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="flex items-center gap-1 text-red-400">
|
||||
<AlertTriangle className="h-3 w-3" /> {n.code}
|
||||
</span>
|
||||
{n.suggestion ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applySuggestion(n.code, n.suggestion!)}
|
||||
className="flex items-center gap-1 font-sans text-zinc-400 hover:text-amber-400"
|
||||
>
|
||||
<Link2 className="h-3 w-3" /> Use {n.suggestion}
|
||||
</button>
|
||||
) : (
|
||||
<span className="block font-sans text-zinc-500">Not in project; flag only</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="min-w-[16rem] px-2 py-2">
|
||||
<textarea
|
||||
value={s.text}
|
||||
onChange={(e) => updateNote(ri, n.key, { text: e.target.value })}
|
||||
rows={Math.min(5, Math.max(1, Math.ceil(s.text.length / 60)))}
|
||||
className={cn(field, "w-full resize-y py-1 leading-snug")}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{n.versions.length > 0 ? (
|
||||
<select
|
||||
value={s.versionId ?? ""}
|
||||
onChange={(e) => updateNote(ri, n.key, { versionId: e.target.value || null, frame: null })}
|
||||
className={cn(field, "h-7")}
|
||||
>
|
||||
{n.versions.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
{o.sent ? ` · sent${o.sharedAt ? " " + format(new Date(o.sharedAt), "d MMM") : ""}` : " · not sent"}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Board only</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-zinc-500">{n.shot ? "No versions; board only" : "—"}</span>
|
||||
)}
|
||||
{v && !v.sent && <span className="mt-1 block text-amber-400/80">Not sent to client</span>}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{s.versionId ? (
|
||||
<>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={s.frame ?? (frame != null ? String(frame) : "")}
|
||||
onChange={(e) => updateNote(ri, n.key, { frame: e.target.value.replace(/\D/g, "") })}
|
||||
placeholder="0"
|
||||
className={cn(field, "h-7 w-16 font-mono")}
|
||||
/>
|
||||
{n.quotedFrame != null && s.frame === null && v && (
|
||||
<span className="mt-1 block whitespace-nowrap text-zinc-500">
|
||||
{v.fromComp ? `comp ${n.quotedFrame}` : `as quoted`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
{v?.taskId ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.setChanges}
|
||||
disabled={v.taskStatus === "CHANGES"}
|
||||
title={v.taskStatus === "CHANGES" ? "Already in Changes" : "Move the task to Changes"}
|
||||
onChange={(e) => updateNote(ri, n.key, { setChanges: e.target.checked })}
|
||||
className="mt-1 accent-amber-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-zinc-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button variant="ghost" onClick={() => setPreview(null)} disabled={isPending}>
|
||||
<ArrowLeft /> Edit paste
|
||||
</Button>
|
||||
<Button onClick={save} disabled={isPending || included.length === 0}>
|
||||
{isPending ? "Saving…" : `Save ${included.length} note${included.length === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { redirect } from "next/navigation";
|
||||
import { suggestShot } from "@/lib/board-parse";
|
||||
import { BoardClient, type BoardProject } from "./BoardClient";
|
||||
|
||||
export const metadata = { title: "Board" };
|
||||
|
||||
// Done lines stay visible this long so "what did we get through" is answerable
|
||||
const DONE_WINDOW_MS = 7 * 86400000;
|
||||
|
||||
export default async function BoardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ who?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
|
||||
const { who } = await searchParams;
|
||||
const doneSince = new Date(Date.now() - DONE_WINDOW_MS);
|
||||
|
||||
const [projects, users] = await Promise.all([
|
||||
db.project.findMany({
|
||||
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||
orderBy: [{ status: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
status: true,
|
||||
boardStatus: true,
|
||||
boardStatusAt: true,
|
||||
milestones: { orderBy: { date: "asc" } },
|
||||
boardItems: {
|
||||
where: { OR: [{ doneAt: null }, { doneAt: { gte: doneSince } }] },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, image: true } },
|
||||
round: { select: { id: true, title: true, dueAt: true } },
|
||||
shot: {
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.user.findMany({
|
||||
where: { isActive: true, role: { not: "CLIENT" } },
|
||||
select: { id: true, name: true, email: true, image: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
|
||||
// Latest version per linked shot — versions hang off the shot or its task
|
||||
const linkedShotIds = [...new Set(projects.flatMap((p) => p.boardItems.map((i) => i.shotId).filter(Boolean) as string[]))];
|
||||
const latestByShot = new Map<string, { id: string; versionNumber: number; approvalStatus: string; isClientVisible: boolean }>();
|
||||
if (linkedShotIds.length) {
|
||||
const versions = await db.version.findMany({
|
||||
where: { OR: [{ shotId: { in: linkedShotIds } }, { task: { shotId: { in: linkedShotIds } } }] },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true, versionNumber: true, approvalStatus: true, isClientVisible: true,
|
||||
shotId: true, task: { select: { shotId: true } },
|
||||
},
|
||||
});
|
||||
for (const v of versions) {
|
||||
const sid = v.shotId ?? v.task?.shotId;
|
||||
if (sid && !latestByShot.has(sid)) latestByShot.set(sid, v);
|
||||
}
|
||||
}
|
||||
|
||||
// Pasted-feedback lines link to the comment they were saved as
|
||||
const commentIds = projects.flatMap((p) => p.boardItems.map((i) => i.commentId).filter(Boolean) as string[]);
|
||||
const commentVersion = new Map(
|
||||
(commentIds.length
|
||||
? await db.comment.findMany({ where: { id: { in: commentIds } }, select: { id: true, versionId: true, frameNumber: true } })
|
||||
: []
|
||||
).map((c) => [c.id, c]),
|
||||
);
|
||||
const shotCounts = await db.shot.groupBy({
|
||||
by: ["projectId", "status"],
|
||||
where: { projectId: { in: projectIds } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
// Lines whose code did not match a shot when typed: the shot may exist now,
|
||||
// or there may be an obvious near-miss (DH2_004_100 → DH2_VFX_004_100).
|
||||
const flaggedProjects = projects.filter((p) => p.boardItems.some((i) => i.code && !i.shotId));
|
||||
const shotsByProject = new Map(
|
||||
await Promise.all(
|
||||
flaggedProjects.map(async (p) => [
|
||||
p.id,
|
||||
await db.shot.findMany({
|
||||
where: { projectId: p.id },
|
||||
select: { id: true, shotCode: true, clientCode: true },
|
||||
}),
|
||||
] as const),
|
||||
),
|
||||
);
|
||||
|
||||
const data: BoardProject[] = projects.map((p) => {
|
||||
const counts = shotCounts.filter((c) => c.projectId === p.id);
|
||||
const shots = shotsByProject.get(p.id) ?? [];
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
code: p.code,
|
||||
status: p.status,
|
||||
boardStatus: p.boardStatus,
|
||||
boardStatusAt: p.boardStatusAt?.toISOString() ?? null,
|
||||
shotTotal: counts.reduce((n, c) => n + c._count._all, 0),
|
||||
shotComplete: counts.find((c) => c.status === "COMPLETE")?._count._all ?? 0,
|
||||
milestones: p.milestones.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
date: m.date.toISOString(),
|
||||
doneAt: m.doneAt?.toISOString() ?? null,
|
||||
})),
|
||||
items: p.boardItems.map((i) => {
|
||||
const v = i.shot ? latestByShot.get(i.shot.id) : undefined;
|
||||
const nowMatches = !i.shotId && i.code
|
||||
? shots.find((s) => s.shotCode.toUpperCase() === i.code!.toUpperCase())
|
||||
: undefined;
|
||||
const suggestion = !i.shotId && i.code && !nowMatches ? suggestShot(i.code, shots) : null;
|
||||
return {
|
||||
id: i.id,
|
||||
text: i.text,
|
||||
code: i.code,
|
||||
group: i.group,
|
||||
waitingOn: i.waitingOn,
|
||||
round: i.round ? { id: i.round.id, title: i.round.title, dueAt: i.round.dueAt?.toISOString() ?? null } : null,
|
||||
comment: i.commentId && commentVersion.has(i.commentId)
|
||||
? { versionId: commentVersion.get(i.commentId)!.versionId, frame: commentVersion.get(i.commentId)!.frameNumber }
|
||||
: null,
|
||||
doneAt: i.doneAt?.toISOString() ?? null,
|
||||
assignee: i.assignee,
|
||||
shot: i.shot
|
||||
? {
|
||||
id: i.shot.id,
|
||||
shotCode: i.shot.shotCode,
|
||||
status: i.shot.status,
|
||||
latest: v
|
||||
? {
|
||||
id: v.id,
|
||||
versionNumber: v.versionNumber,
|
||||
approvalStatus: v.approvalStatus,
|
||||
isClientVisible: v.isClientVisible,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
// Shot created since the line was typed: offer to link it
|
||||
relinkTo: nowMatches?.shotCode ?? suggestion?.shotCode ?? null,
|
||||
relinkIsExact: !!nowMatches,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return <BoardClient projects={data} users={users} who={who ?? null} />;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export default async function ClientDetailPage({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Back */}
|
||||
<Link
|
||||
href="/clients"
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function ClientsPage() {
|
||||
const clients = await getClients();
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -1,58 +1,69 @@
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { StatsCards } from "@/components/dashboard/StatsCards";
|
||||
import { BlockedQueue } from "@/components/dashboard/BlockedQueue";
|
||||
import { FarmStrip } from "@/components/dashboard/FarmStrip";
|
||||
import { DeliveryCountdown } from "@/components/dashboard/DeliveryCountdown";
|
||||
import { QcQueue } from "@/components/dashboard/QcQueue";
|
||||
import { AwaitingDelivery } from "@/components/dashboard/AwaitingDelivery";
|
||||
import { LogRequestButton } from "@/components/dashboard/LogRequestButton";
|
||||
import { RecentDeliveries } from "@/components/dashboard/RecentDeliveries";
|
||||
import { WhoseCourt } from "@/components/dashboard/WhoseCourt";
|
||||
import { ShotQueue } from "@/components/dashboard/ShotQueue";
|
||||
import { RecentActivity } from "@/components/dashboard/RecentActivity";
|
||||
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
|
||||
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
|
||||
import {
|
||||
getAwaitingDelivery,
|
||||
getBlockedExports,
|
||||
getDeliveryCountdown,
|
||||
getFarmStatus,
|
||||
getOpsCounts,
|
||||
getQcQueue,
|
||||
getQcStaleHours,
|
||||
getRecentDeliveries,
|
||||
getWhoseCourt,
|
||||
} from "@/lib/dashboard/ops";
|
||||
import { listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
|
||||
import type { DashboardStats } from "@/types";
|
||||
|
||||
export const metadata = { title: "Dashboard" };
|
||||
|
||||
// Queue depth and machine state are stale the moment they are cached.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* The dashboard answers one question: can we ship what is due, and what is
|
||||
* stopping us? Everything on it is pipeline state — exports, QC, deliveries,
|
||||
* worker heartbeats — rather than anything a person has to maintain by hand.
|
||||
*/
|
||||
async function getDashboardData(userId: string, role: string) {
|
||||
const isArtist = role === "ARTIST";
|
||||
const isClient = role === "CLIENT";
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const todayEnd = new Date(todayStart.getTime() + 86400000);
|
||||
|
||||
const [awaitingReview, needsRevisions, approved, activeProjects,
|
||||
tasksDueToday, tasksInReview, tasksOverdue, myTasksCount] =
|
||||
// Producers see the floor; an artist sees the failures that are theirs.
|
||||
const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, awaitingDelivery, openRequests, court] =
|
||||
await Promise.all([
|
||||
db.version.count({ where: { approvalStatus: "PENDING_REVIEW" } }),
|
||||
db.version.count({ where: { approvalStatus: "NEEDS_CHANGES" } }),
|
||||
db.version.count({ where: { approvalStatus: "APPROVED" } }),
|
||||
db.project.count({ where: { status: "ACTIVE" } }),
|
||||
db.task.count({
|
||||
where: {
|
||||
dueDate: { gte: todayStart, lt: todayEnd },
|
||||
status: { not: "DONE" },
|
||||
...(isArtist ? { assignedArtistId: userId } : {}),
|
||||
},
|
||||
}),
|
||||
db.task.count({
|
||||
where: {
|
||||
status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] },
|
||||
...(isArtist ? { assignedArtistId: userId } : {}),
|
||||
},
|
||||
}),
|
||||
db.task.count({
|
||||
where: {
|
||||
dueDate: { lt: todayStart },
|
||||
status: { not: "DONE" },
|
||||
...(isArtist ? { assignedArtistId: userId } : {}),
|
||||
},
|
||||
}),
|
||||
db.task.count({
|
||||
where: { assignedArtistId: userId, status: { not: "DONE" } },
|
||||
}),
|
||||
getOpsCounts(isArtist ? userId : undefined),
|
||||
getBlockedExports(isArtist ? { artistId: userId } : {}),
|
||||
getFarmStatus(),
|
||||
getDeliveryCountdown(),
|
||||
isArtist || isClient ? [] : getQcQueue(),
|
||||
getQcStaleHours(),
|
||||
isArtist || isClient ? [] : getRecentDeliveries(),
|
||||
isArtist || isClient ? [] : getAwaitingDelivery(),
|
||||
isArtist || isClient ? [] : listDeliveryRequests({ openOnly: true }),
|
||||
isArtist || isClient ? null : getWhoseCourt(),
|
||||
]);
|
||||
|
||||
const shotsWhere = isArtist ? { artistId: userId } : {};
|
||||
// An artist's own shots come first; everyone else sees the floor.
|
||||
const shots = await db.shot.findMany({
|
||||
where: { ...shotsWhere, status: { not: "COMPLETE" } },
|
||||
where: {
|
||||
...(isArtist ? { artistId: userId } : {}),
|
||||
status: { not: "COMPLETE" },
|
||||
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||
},
|
||||
take: 15,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
orderBy: [{ dueDate: { sort: "asc", nulls: "last" } }, { updatedAt: "desc" }],
|
||||
include: {
|
||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||
versions: {
|
||||
@@ -63,12 +74,8 @@ async function getDashboardData(userId: string, role: string) {
|
||||
},
|
||||
});
|
||||
|
||||
// My tasks (for widget)
|
||||
const myTasks = await db.task.findMany({
|
||||
where: {
|
||||
assignedArtistId: userId,
|
||||
status: { not: "DONE" },
|
||||
},
|
||||
where: { assignedArtistId: userId, status: { not: "DONE" } },
|
||||
take: 8,
|
||||
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
|
||||
include: {
|
||||
@@ -78,7 +85,6 @@ async function getDashboardData(userId: string, role: string) {
|
||||
},
|
||||
});
|
||||
|
||||
// Tasks in review (for supervisors/producers)
|
||||
const reviewTasks = isArtist || isClient ? [] : await db.task.findMany({
|
||||
where: { status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] } },
|
||||
take: 8,
|
||||
@@ -91,14 +97,8 @@ async function getDashboardData(userId: string, role: string) {
|
||||
},
|
||||
});
|
||||
|
||||
const activity = await db.notification.findMany({
|
||||
where: isClient ? { userId } : {},
|
||||
take: 20,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { user: { select: { name: true, image: true } } },
|
||||
});
|
||||
|
||||
// Schedule data (for producers/supervisors/admins)
|
||||
// Schedule overview (producers/supervisors/admins) — unchanged; it reads the
|
||||
// schedule page's own data rather than anything the pipeline owns.
|
||||
const scheduledTasks =
|
||||
!isArtist && !isClient
|
||||
? await db.task.findMany({
|
||||
@@ -119,46 +119,59 @@ async function getDashboardData(userId: string, role: string) {
|
||||
!isArtist && !isClient
|
||||
? await db.user.findMany({
|
||||
where: { isActive: true, role: { not: "CLIENT" } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
role: true,
|
||||
},
|
||||
select: { id: true, name: true, email: true, image: true, role: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const stats: DashboardStats = {
|
||||
awaitingReview,
|
||||
needsRevisions,
|
||||
approved,
|
||||
overdue: tasksOverdue,
|
||||
activeProjects,
|
||||
tasksDueToday,
|
||||
tasksInReview,
|
||||
tasksOverdue,
|
||||
myTasksCount,
|
||||
};
|
||||
const stats: DashboardStats = counts;
|
||||
|
||||
return { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists };
|
||||
return {
|
||||
stats,
|
||||
blocked,
|
||||
farm,
|
||||
episodes,
|
||||
qcQueue,
|
||||
qcStaleHours,
|
||||
deliveries,
|
||||
awaitingDelivery,
|
||||
openRequests,
|
||||
court,
|
||||
shots,
|
||||
myTasks,
|
||||
reviewTasks,
|
||||
scheduledTasks,
|
||||
scheduleArtists,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
|
||||
const { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists } =
|
||||
await getDashboardData(session.user.id, session.user.role);
|
||||
|
||||
const isClient = session.user.role === "CLIENT";
|
||||
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
|
||||
session.user.role
|
||||
);
|
||||
const isArtistRole = session.user.role === "ARTIST";
|
||||
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||
const {
|
||||
stats,
|
||||
blocked,
|
||||
farm,
|
||||
episodes,
|
||||
qcQueue,
|
||||
qcStaleHours,
|
||||
deliveries,
|
||||
awaitingDelivery,
|
||||
openRequests,
|
||||
court,
|
||||
shots,
|
||||
myTasks,
|
||||
reviewTasks,
|
||||
scheduledTasks,
|
||||
scheduleArtists,
|
||||
} = await getDashboardData(session.user.id, session.user.role);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
<div className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-zinc-400 mt-1">
|
||||
@@ -166,7 +179,82 @@ export default async function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<StatsCards stats={stats} />
|
||||
{!isClient && <StatsCards stats={stats} />}
|
||||
|
||||
{/* Rows only clear by being dealt with; the heading stays so there is
|
||||
always somewhere to log what a client just asked for. */}
|
||||
{!isClient && !isArtistRole && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2
|
||||
className={
|
||||
openRequests.length + awaitingDelivery.length > 0
|
||||
? "text-sm font-semibold text-amber-400 uppercase tracking-wide"
|
||||
: "text-sm font-semibold text-muted-foreground uppercase tracking-wide"
|
||||
}
|
||||
>
|
||||
Awaiting delivery
|
||||
</h2>
|
||||
{canSeeSchedule && <LogRequestButton />}
|
||||
</div>
|
||||
<AwaitingDelivery requests={openRequests} packages={awaitingDelivery} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isClient && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Delivery countdown
|
||||
</h2>
|
||||
<DeliveryCountdown episodes={episodes} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isClient && (
|
||||
<div id="blocked" className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start scroll-mt-6">
|
||||
<div className="xl:col-span-2 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{isArtistRole ? "Your blocked shots" : "Blocked — needs a human"}
|
||||
</h2>
|
||||
<BlockedQueue blocked={blocked} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Render farm
|
||||
</h2>
|
||||
<FarmStrip farm={farm} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isClient && !isArtistRole && (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start">
|
||||
<div className="xl:col-span-2 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
QC queue
|
||||
</h2>
|
||||
<QcQueue entries={qcQueue} staleHours={qcStaleHours} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Deliveries
|
||||
</h2>
|
||||
<RecentDeliveries deliveries={deliveries} />
|
||||
</div>
|
||||
{court && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Whose court
|
||||
</h2>
|
||||
<WhoseCourt court={court} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isClient && (
|
||||
<TaskWidgets
|
||||
@@ -181,30 +269,16 @@ export default async function DashboardPage() {
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Schedule Overview
|
||||
</h2>
|
||||
<ScheduleWidgets
|
||||
scheduledTasks={scheduledTasks as any}
|
||||
artists={scheduleArtists}
|
||||
/>
|
||||
<ScheduleWidgets scheduledTasks={scheduledTasks as any} artists={scheduleArtists} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Shot Queue
|
||||
</h2>
|
||||
<div className="rounded-lg border border-border bg-card p-1">
|
||||
<ShotQueue shots={shots as any} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<div className="rounded-lg border border-border bg-card h-[400px] overflow-hidden">
|
||||
<RecentActivity activities={activity as any} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{session.user.role === "ARTIST" ? "My shots" : "Shots in flight"}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-border bg-card p-1">
|
||||
<ShotQueue shots={shots as any} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-10
@@ -2,6 +2,7 @@ import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { MobileNavProvider } from "@/components/layout/MobileNav";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
@@ -12,18 +13,20 @@ export default async function DashboardLayout({
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 overflow-hidden print:block print:h-auto print:overflow-visible">
|
||||
<div className="print:hidden">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden print:block print:overflow-visible">
|
||||
<MobileNavProvider>
|
||||
<div className="flex h-screen bg-zinc-950 overflow-hidden print:block print:h-auto print:overflow-visible">
|
||||
<div className="print:hidden">
|
||||
<Header />
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden print:block print:overflow-visible">
|
||||
<div className="print:hidden">
|
||||
<Header />
|
||||
</div>
|
||||
<main className="flex-1 overflow-auto print:overflow-visible">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<main className="flex-1 overflow-auto print:overflow-visible">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</MobileNavProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package } from "lucide-react";
|
||||
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package, ChevronLeft, ChevronRight, Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -51,37 +53,79 @@ interface QueueExport {
|
||||
|
||||
interface Props {
|
||||
projects: { id: string; name: string; code: string }[];
|
||||
/** Status to open filtered to, from `?status=` (see page.tsx). */
|
||||
initialStatus?: string;
|
||||
}
|
||||
|
||||
export function PipelineQueueClient({ projects }: Props) {
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const BLENDER_ACTIVE = ["QUEUED", "CLAIMED", "RUNNING"];
|
||||
|
||||
export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [projectId, setProjectId] = useState<string>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>(initialStatus);
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
|
||||
|
||||
// Searched on the server (the table is paged there), so typing is debounced
|
||||
// rather than firing a request per keystroke.
|
||||
useEffect(() => {
|
||||
const next = searchInput.trim();
|
||||
if (next === search) return;
|
||||
const t = setTimeout(() => {
|
||||
setSearch(next);
|
||||
setPage(1);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput, search]);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["pipeline-queue", projectId, statusFilter],
|
||||
queryKey: ["pipeline-queue", projectId, statusFilter, search, page],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
const params = new URLSearchParams({ limit: String(PAGE_SIZE), page: String(page) });
|
||||
if (projectId !== "all") params.set("projectId", projectId);
|
||||
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||
if (search) params.set("q", search);
|
||||
const res = await fetch(`/api/render/queue?${params}`);
|
||||
if (!res.ok) throw new Error("Failed to load queue");
|
||||
return res.json() as Promise<{ exports: QueueExport[]; pagination: { total: number } }>;
|
||||
return res.json() as Promise<{
|
||||
exports: QueueExport[];
|
||||
pagination: { page: number; total: number; pages: number };
|
||||
statusCounts: Record<string, number>;
|
||||
}>;
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
// Keep the current page on screen while the next one loads, instead of
|
||||
// flashing "Loading…" on every page turn.
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
// Fetched here rather than in the tab so the tab label can say how many are
|
||||
// running: a Blender render must not become invisible by sitting in a tab.
|
||||
const { data: blenderData } = useQuery({
|
||||
queryKey: ["blender-jobs", projectId],
|
||||
queryFn: async () => {
|
||||
const qs = 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,
|
||||
});
|
||||
|
||||
const exports = data?.exports ?? [];
|
||||
const counts = {
|
||||
queued: exports.filter((e) => e.status === "QUEUED").length,
|
||||
rendering: exports.filter((e) => e.status === "RENDERING").length,
|
||||
failed: exports.filter((e) => FAILED_STATUSES.includes(e.status)).length,
|
||||
readyForQc: exports.filter((e) => e.status === "READY_FOR_QC").length,
|
||||
readyForDelivery: exports.filter((e) => e.status === "READY_FOR_DELIVERY").length,
|
||||
};
|
||||
const pagination = data?.pagination;
|
||||
const statusCounts = data?.statusCounts ?? {};
|
||||
const countOf = (statuses: string[]) => statuses.reduce((n, s) => n + (statusCounts[s] ?? 0), 0);
|
||||
const totalExports = Object.values(statusCounts).reduce((n, c) => n + c, 0);
|
||||
const blenderJobs = blenderData?.jobs ?? [];
|
||||
const blenderActive = blenderJobs.filter((j) => BLENDER_ACTIVE.includes(j.status)).length;
|
||||
const blenderFailed = blenderJobs.filter((j) => ["FAILED", "EXPIRED"].includes(j.status)).length;
|
||||
|
||||
async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") {
|
||||
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
|
||||
@@ -94,30 +138,56 @@ export function PipelineQueueClient({ projects }: Props) {
|
||||
queryClient.invalidateQueries({ queryKey: ["pipeline-queue"] });
|
||||
}
|
||||
|
||||
const cards: { label: string; value: number; className?: string }[] = [
|
||||
{ label: "Queued", value: counts.queued },
|
||||
{ label: "Rendering", value: counts.rendering, className: "text-blue-400" },
|
||||
{ label: "Failed", value: counts.failed, className: "text-red-400" },
|
||||
{ label: "Ready for QC", value: counts.readyForQc, className: "text-amber-400" },
|
||||
{ label: "Ready for Delivery", value: counts.readyForDelivery, className: "text-green-400" },
|
||||
const cards: { label: string; value: number; status?: string; className?: string }[] = [
|
||||
{ label: "Queued", value: countOf(["QUEUED"]), status: "QUEUED" },
|
||||
{ label: "Rendering", value: countOf(["RENDERING"]), status: "RENDERING", className: "text-blue-400" },
|
||||
// Several statuses count as failed and the filter takes one, so this card
|
||||
// is a figure only.
|
||||
{ label: "Failed", value: countOf(FAILED_STATUSES), className: "text-red-400" },
|
||||
{ label: "Ready for QC", value: countOf(["READY_FOR_QC"]), status: "READY_FOR_QC", className: "text-amber-400" },
|
||||
{
|
||||
label: "Ready for Delivery",
|
||||
value: countOf(["READY_FOR_DELIVERY"]),
|
||||
status: "READY_FOR_DELIVERY",
|
||||
className: "text-green-400",
|
||||
},
|
||||
];
|
||||
|
||||
const changeProject = (id: string) => {
|
||||
setProjectId(id);
|
||||
setPage(1);
|
||||
};
|
||||
const changeStatus = (status: string) => {
|
||||
setStatusFilter(status);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const firstRow = pagination && pagination.total > 0 ? (pagination.page - 1) * PAGE_SIZE + 1 : 0;
|
||||
const lastRow = pagination ? Math.min(pagination.page * PAGE_SIZE, pagination.total) : 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Render Queue</h1>
|
||||
<p className="text-sm text-zinc-400 mt-1">
|
||||
Exports queued from the AE panel — live status, retries and history
|
||||
After Effects exports and Blender renders — live status, retries and history
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/pipeline/deliveries">
|
||||
<Button variant="outline" size="sm">
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Deliveries
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={projectId} onValueChange={changeProject}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All projects</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Link href="/pipeline/deliveries">
|
||||
<Button variant="outline" size="sm">
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
@@ -136,152 +206,231 @@ export function PipelineQueueClient({ projects }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
|
||||
<div className={cn("text-2xl font-semibold text-white", c.className)}>{c.value}</div>
|
||||
<div className="text-xs text-zinc-400 mt-0.5">{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All projects</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
|
||||
<th className="px-4 py-2.5 font-medium">Shot</th>
|
||||
<th className="px-4 py-2.5 font-medium">Version</th>
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
<th className="px-4 py-2.5 font-medium w-48">Progress</th>
|
||||
<th className="px-4 py-2.5 font-medium">Machine</th>
|
||||
<th className="px-4 py-2.5 font-medium">Attempt</th>
|
||||
<th className="px-4 py-2.5 font-medium">Queued</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : exports.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
|
||||
No exports yet — queue one from the After Effects panel
|
||||
</td>
|
||||
</tr>
|
||||
<Tabs defaultValue="exports" className="space-y-4">
|
||||
<TabsList className="bg-zinc-900 border border-zinc-800">
|
||||
<TabsTrigger value="exports">
|
||||
After Effects exports
|
||||
<span className="ml-2 text-xs text-zinc-500">{totalExports}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="blender">
|
||||
Blender renders
|
||||
{blenderActive > 0 ? (
|
||||
<span className="ml-2 rounded-full bg-blue-500/20 px-1.5 text-xs text-blue-400">
|
||||
{blenderActive} active
|
||||
</span>
|
||||
) : blenderFailed > 0 ? (
|
||||
<span className="ml-2 rounded-full bg-red-500/20 px-1.5 text-xs text-red-400">
|
||||
{blenderFailed} failed
|
||||
</span>
|
||||
) : (
|
||||
exports.map((e) => (
|
||||
<tr key={e.id} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/pipeline/exports/${e.id}`} className="text-white hover:text-amber-400 font-medium">
|
||||
{e.shotCode}
|
||||
</Link>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{e.projectCode}
|
||||
{e.episode ? ` · ep ${e.episode}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-300">{e.versionString}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
|
||||
)}
|
||||
title={e.job?.errorMessage ?? undefined}
|
||||
>
|
||||
{statusLabel(e.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{e.status === "RENDERING" && e.job ? (
|
||||
<div className="space-y-1">
|
||||
<Progress value={e.job.progress * 100} className="h-1.5" />
|
||||
<div className="text-xs text-zinc-500">
|
||||
{e.job.currentFrame != null && e.job.totalFrames != null
|
||||
? `frame ${e.job.currentFrame}/${e.job.totalFrames} · `
|
||||
: ""}
|
||||
ETA {formatEta(e.job.etaSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400">{e.job?.machineName ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400">
|
||||
{e.job ? `${e.job.attempt}` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{FAILED_STATUSES.includes(e.status) && (
|
||||
<Button variant="ghost" size="icon-sm" title="Retry" onClick={() => action(e.id, "retry")}>
|
||||
<RotateCcw className="h-4 w-4 text-amber-400" />
|
||||
</Button>
|
||||
)}
|
||||
{ACTIVE_STATUSES.includes(e.status) && (
|
||||
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => action(e.id, "cancel")}>
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Mark done manually (rendered outside the pipeline)"
|
||||
onClick={() => action(e.id, "mark-done")}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
<span className="ml-2 text-xs text-zinc-500">{blenderJobs.length}</span>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<BlenderQueue projectId={projectId} />
|
||||
<TabsContent value="exports" className="space-y-4 mt-0">
|
||||
{/* Summary cards — each one with a status filters the table to what it counts */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{cards.map((c) => {
|
||||
const selected = c.status !== undefined && statusFilter === c.status;
|
||||
const body = (
|
||||
<>
|
||||
<div className={cn("text-2xl font-semibold text-white", c.className)}>{c.value}</div>
|
||||
<div className="text-xs text-zinc-400 mt-0.5">{c.label}</div>
|
||||
</>
|
||||
);
|
||||
return c.status ? (
|
||||
<button
|
||||
key={c.label}
|
||||
type="button"
|
||||
onClick={() => changeStatus(selected ? "all" : c.status!)}
|
||||
className={cn(
|
||||
"rounded-lg border bg-zinc-900 px-4 py-3 text-left transition-colors hover:border-zinc-600",
|
||||
selected ? "border-zinc-500" : "border-zinc-800"
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Escape" && setSearchInput("")}
|
||||
placeholder="Search shots, e.g. UNG_114"
|
||||
aria-label="Search exports by shot code"
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchInput("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={changeStatus}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
|
||||
<th className="px-4 py-2.5 font-medium">Shot</th>
|
||||
<th className="px-4 py-2.5 font-medium">Version</th>
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
<th className="px-4 py-2.5 font-medium w-48">Progress</th>
|
||||
<th className="px-4 py-2.5 font-medium">Machine</th>
|
||||
<th className="px-4 py-2.5 font-medium">Attempt</th>
|
||||
<th className="px-4 py-2.5 font-medium">Queued</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : exports.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
|
||||
{search
|
||||
? `No exports match “${search}”`
|
||||
: statusFilter === "all"
|
||||
? "No exports yet — queue one from the After Effects panel"
|
||||
: `No exports are ${statusLabel(statusFilter).toLowerCase()}`}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
exports.map((e) => (
|
||||
<tr key={e.id} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/pipeline/exports/${e.id}`} className="text-white hover:text-amber-400 font-medium">
|
||||
{e.shotCode}
|
||||
</Link>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{e.projectCode}
|
||||
{e.episode ? ` · ep ${e.episode}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-300">{e.versionString}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
|
||||
)}
|
||||
title={e.job?.errorMessage ?? undefined}
|
||||
>
|
||||
{statusLabel(e.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{e.status === "RENDERING" && e.job ? (
|
||||
<div className="space-y-1">
|
||||
<Progress value={e.job.progress * 100} className="h-1.5" />
|
||||
<div className="text-xs text-zinc-500">
|
||||
{e.job.currentFrame != null && e.job.totalFrames != null
|
||||
? `frame ${e.job.currentFrame}/${e.job.totalFrames} · `
|
||||
: ""}
|
||||
ETA {formatEta(e.job.etaSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400">{e.job?.machineName ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400">{e.job ? `${e.job.attempt}` : "—"}</td>
|
||||
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{FAILED_STATUSES.includes(e.status) && (
|
||||
<Button variant="ghost" size="icon-sm" title="Retry" onClick={() => action(e.id, "retry")}>
|
||||
<RotateCcw className="h-4 w-4 text-amber-400" />
|
||||
</Button>
|
||||
)}
|
||||
{ACTIVE_STATUSES.includes(e.status) && (
|
||||
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => action(e.id, "cancel")}>
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Mark done manually (rendered outside the pipeline)"
|
||||
onClick={() => action(e.id, "mark-done")}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.pages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-zinc-400">
|
||||
<span>
|
||||
{firstRow}–{lastRow} of {pagination.total}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs">
|
||||
Page {pagination.page} of {pagination.pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= pagination.pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="blender" className="mt-0">
|
||||
<BlenderQueue jobs={blenderJobs} loaded={blenderData !== undefined} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -318,24 +467,13 @@ const BLENDER_STATUS_STYLES: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* appear in the exports table — but they share the queue, and a render nobody
|
||||
* can see is a render nobody can retry.
|
||||
*/
|
||||
function BlenderQueue({ projectId }: { projectId: string }) {
|
||||
function BlenderQueue({ jobs, loaded }: { jobs: BlenderJobRow[]; loaded: boolean }) {
|
||||
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) {
|
||||
@@ -345,14 +483,16 @@ function BlenderQueue({ projectId }: { projectId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: ["blender-jobs"] });
|
||||
}
|
||||
|
||||
const jobs = data?.jobs ?? [];
|
||||
if (jobs.length === 0) return null;
|
||||
if (jobs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 px-4 py-10 text-center text-sm text-zinc-500">
|
||||
{loaded ? "No Blender renders yet — submit one from the Blender panel" : "Loading…"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
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 { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw, Upload } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { handoffStage } from "@/lib/render-pipeline/delivery-handoff";
|
||||
import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
|
||||
import { RecordShareDialog, RecordUploadDialog } from "./HandoffDialogs";
|
||||
import { RequestsPanel } from "./RequestsPanel";
|
||||
|
||||
interface ReadyExport {
|
||||
exportId: string;
|
||||
@@ -35,14 +38,25 @@ interface PackageRow {
|
||||
rootPath: string | null;
|
||||
errorMessage: string | null;
|
||||
projectName: string;
|
||||
projectCode: string;
|
||||
createdAt: string;
|
||||
builtAt: string | null;
|
||||
uploadUrl: string | null;
|
||||
uploadedAt: string | null;
|
||||
uploadedByName: string | null;
|
||||
sharedWith: string | null;
|
||||
sharedNote: string | null;
|
||||
deliveredAt: string | null;
|
||||
deliveredByName: string | null;
|
||||
itemCount: number;
|
||||
verifiedCount: number;
|
||||
exrFileCount: number;
|
||||
movCount: number;
|
||||
totalBytes: number;
|
||||
items: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
clientCode: string | null;
|
||||
versionString: string;
|
||||
status: string;
|
||||
fileCount: number | null;
|
||||
@@ -69,6 +83,10 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
const [projectId, setProjectId] = useState<string>("");
|
||||
const [episode, setEpisode] = useState<string>("");
|
||||
const [building, setBuilding] = useState(false);
|
||||
// Tracked as exclusions so every ready shot — including one that passes QC
|
||||
// while the page is open — starts selected.
|
||||
const [deselected, setDeselected] = useState<Set<string>>(() => new Set());
|
||||
const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null);
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ["pipeline-projects"],
|
||||
@@ -101,14 +119,36 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
async function build(ep: string) {
|
||||
if (!projectId) return;
|
||||
function toggleExport(exportId: string, selected: boolean) {
|
||||
setDeselected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selected) next.delete(exportId);
|
||||
else next.add(exportId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setEpisodeSelected(exportIds: string[], selected: boolean) {
|
||||
setDeselected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of exportIds) {
|
||||
if (selected) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function build(ep: string, exportIds: string[]) {
|
||||
if (!projectId || exportIds.length === 0) return;
|
||||
setBuilding(true);
|
||||
try {
|
||||
// Always send the explicit list: the package holds exactly the shots
|
||||
// ticked on screen, never one that turned ready since the page loaded.
|
||||
const res = await fetch("/api/deliveries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId, episode: ep || null }),
|
||||
body: JSON.stringify({ projectId, episode: ep || null, exportIds }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
@@ -126,21 +166,28 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
function refreshPackages() {
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
|
||||
}
|
||||
|
||||
async function cancelPackage(id: string) {
|
||||
const res = await fetch(`/api/deliveries/${id}/cancel`, { method: "POST" });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ title: "Cancel failed", description: body.error ?? res.statusText, variant: "destructive" });
|
||||
}
|
||||
refreshPackages();
|
||||
}
|
||||
|
||||
// Looked up from the live list so the dialog never acts on a stale row.
|
||||
const handoffPackage = handoff ? packages?.packages.find((p) => p.id === handoff.packageId) : undefined;
|
||||
|
||||
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="p-4 md:p-6 space-y-6 max-w-6xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pipeline">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
@@ -157,6 +204,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
onChange={(e) => {
|
||||
setProjectId(e.target.value);
|
||||
setEpisode("");
|
||||
setDeselected(new Set());
|
||||
}}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
|
||||
>
|
||||
@@ -183,6 +231,8 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RequestsPanel projectId={projectId} canBuild={canBuild} />
|
||||
|
||||
{/* Ready to build */}
|
||||
{projectId && (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
@@ -195,30 +245,69 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
Nothing has passed QC yet. Exports appear here once QC passes them.
|
||||
</div>
|
||||
)}
|
||||
{shown.map((ep) => (
|
||||
{shown.map((ep) => {
|
||||
const allIds = ep.exports.map((x) => x.exportId);
|
||||
const selectedIds = allIds.filter((id) => !deselected.has(id));
|
||||
const allSelected = selectedIds.length === allIds.length;
|
||||
const selectedExports = ep.exports.filter((x) => !deselected.has(x.exportId));
|
||||
const missingChecksum = selectedExports.filter((x) => !x.hasChecksum).length;
|
||||
return (
|
||||
<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-sm text-zinc-400">
|
||||
{canBuild ? `Selected: ${selectedIds.length} of ${ep.count} shots` : `Ready: ${ep.count} shots`}
|
||||
</span>
|
||||
{canBuild && selectedIds.length > 0 && (
|
||||
<span className="ml-3 text-xs text-zinc-500">
|
||||
{formatBytes(selectedExports.reduce((sum, x) => sum + (x.totalBytes ?? 0), 0))}
|
||||
</span>
|
||||
)}
|
||||
{missingChecksum > 0 && (
|
||||
<span className="ml-3 text-xs text-amber-400">
|
||||
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
|
||||
{missingChecksum} 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>
|
||||
<span className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEpisodeSelected(allIds, !allSelected)}
|
||||
>
|
||||
{allSelected ? "Deselect all" : "Select all"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => build(ep.episode, selectedIds)}
|
||||
disabled={building || selectedIds.length === 0}
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Build delivery ({selectedIds.length})
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
{ep.exports.map((x) => {
|
||||
const selected = !deselected.has(x.exportId);
|
||||
return (
|
||||
<div
|
||||
key={x.exportId}
|
||||
className={cn("flex items-center gap-2 text-xs", canBuild && !selected && "opacity-50")}
|
||||
>
|
||||
{canBuild && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => toggleExport(x.exportId, e.target.checked)}
|
||||
aria-label={`Include ${x.shotCode} in the package`}
|
||||
/>
|
||||
)}
|
||||
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
|
||||
{x.shotCode}
|
||||
</Link>
|
||||
@@ -227,10 +316,12 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
|
||||
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -238,7 +329,9 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<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) => (
|
||||
{(packages?.packages ?? []).map((p) => {
|
||||
const stage = handoffStage(p);
|
||||
return (
|
||||
<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>
|
||||
@@ -251,10 +344,16 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-xs",
|
||||
DELIVERY_STATUS_STYLES[p.status] ?? ""
|
||||
// Built is not delivered: a package still owed to the
|
||||
// client must not read as a green "READY".
|
||||
stage ? "bg-amber-500/15 text-amber-400 border-amber-500/30" : (DELIVERY_STATUS_STYLES[p.status] ?? "")
|
||||
)}
|
||||
>
|
||||
{statusLabel(p.status)}
|
||||
{stage === "AWAITING_UPLOAD"
|
||||
? "BUILT — NOT UPLOADED"
|
||||
: stage === "AWAITING_SHARE"
|
||||
? "UPLOADED — LINK NOT SHARED"
|
||||
: statusLabel(p.status)}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{p.episode ? `episode ${p.episode} · ` : ""}
|
||||
@@ -263,17 +362,30 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<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" && (
|
||||
{canBuild && stage === "AWAITING_UPLOAD" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
packageAction(p.id, "delivered");
|
||||
setHandoff({ step: "upload", packageId: p.id });
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2 text-amber-400" />
|
||||
Record upload
|
||||
</Button>
|
||||
)}
|
||||
{canBuild && stage === "AWAITING_SHARE" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setHandoff({ step: "share", packageId: p.id });
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
|
||||
Mark delivered
|
||||
Share link
|
||||
</Button>
|
||||
)}
|
||||
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
|
||||
@@ -282,7 +394,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
packageAction(p.id, "cancel");
|
||||
cancelPackage(p.id);
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-400" />
|
||||
@@ -294,9 +406,31 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
<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.uploadedAt && p.uploadUrl && (
|
||||
<div className="text-xs text-zinc-400 break-all">
|
||||
Uploaded {new Date(p.uploadedAt).toLocaleString()}
|
||||
{p.uploadedByName ? ` by ${p.uploadedByName}` : ""} —{" "}
|
||||
<a href={p.uploadUrl} target="_blank" rel="noreferrer" className="text-sky-400 hover:underline">
|
||||
{p.uploadUrl}
|
||||
</a>
|
||||
{canBuild && stage === "AWAITING_SHARE" && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-zinc-500 hover:text-white underline"
|
||||
onClick={() => setHandoff({ step: "upload", packageId: p.id })}
|
||||
>
|
||||
change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{p.deliveredAt && (
|
||||
<div className="text-xs text-emerald-400">
|
||||
Delivered {new Date(p.deliveredAt).toLocaleString()}
|
||||
{p.sharedWith
|
||||
? `Link shared with ${p.sharedWith} ${new Date(p.deliveredAt).toLocaleString()}`
|
||||
: `Delivered ${new Date(p.deliveredAt).toLocaleString()} — before uploads and shares were recorded`}
|
||||
{p.deliveredByName ? ` by ${p.deliveredByName}` : ""}
|
||||
{p.sharedNote ? ` — ${p.sharedNote}` : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
@@ -325,12 +459,24 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{(packages?.packages ?? []).length === 0 && (
|
||||
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{handoff?.step === "upload" && handoffPackage && (
|
||||
<RecordUploadDialog pkg={handoffPackage} onClose={() => setHandoff(null)} onSaved={refreshPackages} />
|
||||
)}
|
||||
{handoff?.step === "share" && handoffPackage?.uploadUrl && (
|
||||
<RecordShareDialog
|
||||
pkg={{ ...handoffPackage, uploadUrl: handoffPackage.uploadUrl }}
|
||||
onClose={() => setHandoff(null)}
|
||||
onSaved={refreshPackages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { buildShareMessage } from "@/lib/render-pipeline/delivery-handoff";
|
||||
|
||||
export interface HandoffPackage {
|
||||
id: string;
|
||||
packageNumber: number;
|
||||
name: string;
|
||||
projectCode: string;
|
||||
episode: string;
|
||||
rootPath: string | null;
|
||||
uploadUrl: string | null;
|
||||
itemCount: number;
|
||||
exrFileCount: number;
|
||||
movCount: number;
|
||||
totalBytes: number;
|
||||
items: { shotCode: string; clientCode: string | null; versionString: string }[];
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return "size unknown";
|
||||
const gb = bytes / 1_073_741_824;
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1_048_576).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
async function post(path: string, body: unknown): Promise<string | null> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) return null;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return data.error ?? res.statusText;
|
||||
}
|
||||
|
||||
/** Step one: where the folder now lives on the client's side. */
|
||||
export function RecordUploadDialog({
|
||||
pkg,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
pkg: HandoffPackage;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [uploadUrl, setUploadUrl] = useState(pkg.uploadUrl ?? "");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
const error = await post(`/api/deliveries/${pkg.id}/uploaded`, { uploadUrl, contentsConfirmed: confirmed });
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
toast({ title: "Upload not recorded", description: error, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Record upload — #{pkg.packageNumber} {pkg.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste the link to the folder on the client's portal. The package stays on the dashboard as
|
||||
awaiting delivery until the link has also been shared.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-900/60 p-3 text-xs text-zinc-400 space-y-1">
|
||||
<div className="text-zinc-200">
|
||||
The upload should contain {pkg.itemCount} shot{pkg.itemCount === 1 ? "" : "s"} · {pkg.exrFileCount} EXR
|
||||
frames · {pkg.movCount} MOV{pkg.movCount === 1 ? "" : "s"} · {formatSize(pkg.totalBytes)}
|
||||
</div>
|
||||
{pkg.rootPath && <div className="break-all">Source: {pkg.rootPath}</div>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="upload-url">Upload link</Label>
|
||||
<Input
|
||||
id="upload-url"
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
value={uploadUrl}
|
||||
onChange={(e) => setUploadUrl(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
<span>I opened this link and the upload has finished and matches the counts above.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving || !uploadUrl.trim() || !confirmed}>
|
||||
Record upload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Step two: the link went to a person. This is what makes it delivered. */
|
||||
export function RecordShareDialog({
|
||||
pkg,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
pkg: HandoffPackage & { uploadUrl: string };
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [sharedWith, setSharedWith] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const message = buildShareMessage(pkg);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message);
|
||||
toast({ title: "Message copied" });
|
||||
} catch {
|
||||
toast({ title: "Could not copy", description: "Select the text and copy it by hand.", variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
const error = await post(`/api/deliveries/${pkg.id}/delivered`, { sharedWith, note: note || null });
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
toast({ title: "Not marked delivered", description: error, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Share link — #{pkg.packageNumber} {pkg.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send this message to the client, then record who it went to. That marks the package delivered.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="share-message">Message</Label>
|
||||
<Button size="sm" variant="outline" onClick={copy}>
|
||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||
Copy message
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea id="share-message" readOnly value={message} rows={8} className="font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="shared-with">Sent to</Label>
|
||||
<Input
|
||||
id="shared-with"
|
||||
placeholder="Name, and where — e.g. Sam, production WhatsApp group"
|
||||
value={sharedWith}
|
||||
onChange={(e) => setSharedWith(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-note">Note (optional)</Label>
|
||||
<Input id="share-note" value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving || !sharedWith.trim()}>
|
||||
Link sent — mark delivered
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Package, Plus, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||
import {
|
||||
DELIVERABLE_LABEL,
|
||||
LINE_STAGE_LABEL,
|
||||
type Deliverable,
|
||||
type RequestLineStage,
|
||||
} from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
interface RequestRow {
|
||||
id: string;
|
||||
requestNumber: number;
|
||||
projectId: string;
|
||||
projectCode: string;
|
||||
episode: string;
|
||||
deliverable: string;
|
||||
requestedBy: string;
|
||||
note: string | null;
|
||||
dueDate: string | null;
|
||||
createdAt: string;
|
||||
cancelReason: string | null;
|
||||
state: "OPEN" | "CLOSED" | "CANCELLED";
|
||||
lineCount: number;
|
||||
deliveredCount: number;
|
||||
severity: "normal" | "warning" | "critical";
|
||||
lines: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
clientCode: string | null;
|
||||
stage: RequestLineStage;
|
||||
readyExportId: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
const SEVERITY_STYLES: Record<RequestRow["severity"], string> = {
|
||||
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||||
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
critical: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
const STAGE_STYLES: Record<RequestLineStage, string> = {
|
||||
DELIVERED: "text-emerald-400",
|
||||
UPLOADED: "text-amber-400",
|
||||
BUILT: "text-amber-400",
|
||||
BUILDING: "text-blue-400",
|
||||
READY: "text-green-400",
|
||||
IN_QC: "text-zinc-400",
|
||||
NOT_READY: "text-zinc-500",
|
||||
};
|
||||
|
||||
function requestTitle(r: RequestRow): string {
|
||||
const what = DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable;
|
||||
return r.episode ? `${what} for ${r.episode}` : `${what} · ${r.lineCount} shot${r.lineCount === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What clients have asked for. An open request lists every shot with where it
|
||||
* stands, so "did 116 go out?" is answered by the page rather than by memory.
|
||||
*/
|
||||
export function RequestsPanel({ projectId, canBuild }: { projectId: string; canBuild: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [logging, setLogging] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<RequestRow | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["delivery-requests", projectId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/delivery-requests${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
|
||||
if (!res.ok) throw new Error("Failed to load delivery requests");
|
||||
return res.json() as Promise<{ requests: RequestRow[] }>;
|
||||
},
|
||||
// Lines move as packages build and get shared on this same page.
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-requests"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
|
||||
};
|
||||
|
||||
async function build(request: RequestRow) {
|
||||
const exportIds = request.lines.flatMap((l) => (l.readyExportId ? [l.readyExportId] : []));
|
||||
if (!exportIds.length) return;
|
||||
setBusy(true);
|
||||
const res = await fetch("/api/deliveries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId: request.projectId, episode: request.episode || null, exportIds }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setBusy(false);
|
||||
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 for request #${request.requestNumber}`,
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!cancelling) return;
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/delivery-requests/${cancelling.id}/cancel`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
toast({ title: "Not cancelled", description: body.error ?? res.statusText, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setCancelling(null);
|
||||
refresh();
|
||||
}
|
||||
|
||||
const requests = data?.requests ?? [];
|
||||
const open = requests.filter((r) => r.state === "OPEN");
|
||||
const past = requests.filter((r) => r.state !== "OPEN");
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-zinc-800">
|
||||
<span className="text-sm font-medium text-white">Delivery requests</span>
|
||||
{canBuild && (
|
||||
<Button size="sm" variant="outline" onClick={() => setLogging(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Log request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open.length === 0 && <div className="p-4 text-sm text-zinc-500">No open requests.</div>}
|
||||
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{open.map((r) => {
|
||||
const readyCount = r.lines.filter((l) => l.readyExportId).length;
|
||||
return (
|
||||
<details key={r.id} open 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">Request #{r.requestNumber}</span>
|
||||
<span className="text-sm text-zinc-300">{requestTitle(r)}</span>
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs", SEVERITY_STYLES[r.severity])}>
|
||||
{r.deliveredCount} of {r.lineCount} delivered
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{r.projectCode} · asked by {r.requestedBy} · {new Date(r.createdAt).toLocaleDateString()}
|
||||
{r.dueDate ? ` · due ${new Date(r.dueDate).toLocaleDateString()}` : ""}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{canBuild && readyCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
build(r);
|
||||
}}
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Build {readyCount} ready
|
||||
</Button>
|
||||
)}
|
||||
{canBuild && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setReason("");
|
||||
setCancelling(r);
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2 text-red-400" />
|
||||
Cancel request
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{r.note && <div className="text-xs text-zinc-400">{r.note}</div>}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{r.lines.map((line) => (
|
||||
<div key={line.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-zinc-300">{line.shotCode}</span>
|
||||
{line.clientCode && <span className="text-zinc-600">{line.clientCode}</span>}
|
||||
<span className={STAGE_STYLES[line.stage]}>{LINE_STAGE_LABEL[line.stage]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{past.length > 0 && (
|
||||
<details className="border-t border-zinc-800">
|
||||
<summary className="px-4 py-2.5 cursor-pointer text-xs text-zinc-500 hover:text-zinc-300">
|
||||
Closed and cancelled ({past.length})
|
||||
</summary>
|
||||
<div className="px-4 pb-3 space-y-1">
|
||||
{past.map((r) => (
|
||||
<div key={r.id} className="text-xs text-zinc-500">
|
||||
<span className="text-zinc-300">Request #{r.requestNumber}</span> {requestTitle(r)} · {r.projectCode} ·
|
||||
asked by {r.requestedBy} ·{" "}
|
||||
{r.state === "CANCELLED" ? (
|
||||
<span className="text-zinc-400">cancelled — {r.cancelReason}</span>
|
||||
) : (
|
||||
<span className="text-emerald-400">all {r.lineCount} delivered</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<LogRequestDialog
|
||||
open={logging}
|
||||
onClose={() => setLogging(false)}
|
||||
onLogged={refresh}
|
||||
defaultProjectId={projectId || undefined}
|
||||
/>
|
||||
|
||||
{cancelling && (
|
||||
<Dialog open onOpenChange={(o) => !o && !busy && setCancelling(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel request #{cancelling.requestNumber}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{cancelling.lineCount - cancelling.deliveredCount} of {cancelling.lineCount} shots have not been
|
||||
delivered. Cancelling removes the request from Awaiting delivery; it stays in the history with the
|
||||
reason.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
aria-label="Reason"
|
||||
placeholder="Why — e.g. client no longer needs 116"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelling(null)} disabled={busy}>
|
||||
Keep request
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={cancel} disabled={busy || !reason.trim()}>
|
||||
Cancel request
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,10 +155,10 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
|
||||
if (isLoading) return <div className="p-4 md:p-6 text-zinc-500">Loading…</div>;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="p-6 text-red-400">
|
||||
<div className="p-4 md:p-6 text-red-400">
|
||||
{(error as Error)?.message ?? "Export not found"}
|
||||
</div>
|
||||
);
|
||||
@@ -180,7 +180,7 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-5xl">
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pipeline">
|
||||
|
||||
@@ -76,7 +76,7 @@ export function MachinesClient() {
|
||||
const machines = data?.machines ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pipeline">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ExportStatus } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import { PipelineQueueClient } from "./PipelineQueueClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PipelinePage() {
|
||||
export default async function PipelinePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role === "CLIENT") redirect("/dashboard");
|
||||
|
||||
// Lets other pages link straight to a filtered queue — the dashboard's "In
|
||||
// QC" figure is only useful if it leads to the rows it counted. Anything
|
||||
// that is not a real status falls back to the unfiltered queue.
|
||||
const requested = (await searchParams).status;
|
||||
const initialStatus =
|
||||
requested && (Object.values(ExportStatus) as string[]).includes(requested) ? requested : "all";
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||
select: { id: true, name: true, code: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return <PipelineQueueClient projects={projects} />;
|
||||
return <PipelineQueueClient projects={projects} initialStatus={initialStatus} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, startTransition } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
|
||||
import { SequencePlayer, type SequenceClip } from "@/components/player/SequencePlayer";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import {
|
||||
Select,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
ExternalLink,
|
||||
ShieldCheck,
|
||||
Send,
|
||||
ListVideo,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Project {
|
||||
@@ -42,6 +45,12 @@ interface PlaylistVersion {
|
||||
thumbnailUrl: string | null;
|
||||
posterUrl: string | null;
|
||||
fps: number;
|
||||
duration?: number | null;
|
||||
frameCount?: number | null;
|
||||
hqFileUrl?: string | null;
|
||||
handleHead?: number | null;
|
||||
handleTail?: number | null;
|
||||
pictureStartFrame?: number | null;
|
||||
approvalStatus: string;
|
||||
taskTitle: string;
|
||||
}
|
||||
@@ -86,6 +95,10 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
const [activeShot, setActiveShot] = useState<PlaylistShot | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isActioning, setIsActioning] = useState(false);
|
||||
/** Shots checked for the seamless playlist, by shot id. */
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [sequenceMode, setSequenceMode] = useState(false);
|
||||
const lastChecked = useRef<string | null>(null);
|
||||
|
||||
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(userRole);
|
||||
|
||||
@@ -112,6 +125,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
if (cancelled) return;
|
||||
const list: PlaylistShot[] = data.shots ?? [];
|
||||
setShots(list);
|
||||
setSelected(loadSelection(projectId, list));
|
||||
lastChecked.current = null;
|
||||
setSequenceMode(false);
|
||||
// Auto-select the first shot, or keep the current one if it still exists
|
||||
setActiveShot((prev) => {
|
||||
const stillExists = prev && list.some((s) => s.id === prev.id);
|
||||
@@ -134,9 +150,78 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
|
||||
const handleSelectShot = useCallback((shot: PlaylistShot) => {
|
||||
reset();
|
||||
setSequenceMode(false);
|
||||
setActiveShot(shot);
|
||||
}, [reset]);
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(next: Set<string>) => {
|
||||
setSelected(next);
|
||||
if (projectId) saveSelection(projectId, next);
|
||||
if (next.size === 0) setSequenceMode(false);
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
/** Toggle one shot; shift-click sets every shot between it and the last one checked. */
|
||||
const toggleChecked = (shot: PlaylistShot, shift: boolean) => {
|
||||
const next = new Set(selected);
|
||||
const on = !selected.has(shot.id);
|
||||
const from = lastChecked.current ? shots.findIndex((s) => s.id === lastChecked.current) : -1;
|
||||
const to = shots.findIndex((s) => s.id === shot.id);
|
||||
const range = shift && from >= 0 ? shots.slice(Math.min(from, to), Math.max(from, to) + 1) : [shot];
|
||||
for (const s of range) {
|
||||
if (on) next.add(s.id);
|
||||
else next.delete(s.id);
|
||||
}
|
||||
lastChecked.current = shot.id;
|
||||
updateSelection(next);
|
||||
};
|
||||
|
||||
const setGroupChecked = (group: PlaylistShot[], on: boolean) => {
|
||||
const next = new Set(selected);
|
||||
for (const s of group) {
|
||||
if (on) next.add(s.id);
|
||||
else next.delete(s.id);
|
||||
}
|
||||
updateSelection(next);
|
||||
};
|
||||
|
||||
// The playlist, in edit order. Keyed on the versions it plays so a refresh
|
||||
// (e.g. after approving a shot) doesn't restart playback.
|
||||
const clipKey = shots
|
||||
.filter((s) => selected.has(s.id))
|
||||
.map((s) => s.latestVersion.id)
|
||||
.join(",");
|
||||
const clips = useMemo<SequenceClip[]>(
|
||||
() =>
|
||||
shots
|
||||
.filter((s) => selected.has(s.id))
|
||||
.map((s) => ({
|
||||
id: s.latestVersion.id,
|
||||
label: s.shotCode,
|
||||
sublabel: `v${String(s.latestVersion.versionNumber).padStart(3, "0")}`,
|
||||
url: s.latestVersion.fileUrl,
|
||||
hqUrl: s.latestVersion.hqFileUrl,
|
||||
fps: s.latestVersion.fps,
|
||||
duration: s.latestVersion.duration,
|
||||
handles: s.latestVersion,
|
||||
})),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[clipKey]
|
||||
);
|
||||
|
||||
const handleClipChange = useCallback(
|
||||
(clip: SequenceClip) => {
|
||||
// A transition, so re-rendering the page (shot list and all) yields to
|
||||
// the video: rendered in one go, it dropped the frame after every cut.
|
||||
startTransition(() => {
|
||||
setActiveShot(shots.find((s) => s.latestVersion.id === clip.id) ?? null);
|
||||
});
|
||||
},
|
||||
[shots]
|
||||
);
|
||||
|
||||
const refreshShots = useCallback(() => {
|
||||
if (!projectId) return;
|
||||
fetch(`/api/playlist?projectId=${projectId}`)
|
||||
@@ -215,6 +300,33 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
sequenceMode ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSequenceMode(false)}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Exit playlist
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setSequenceMode(true);
|
||||
}}
|
||||
className="h-7 text-xs gap-1"
|
||||
title="Play the checked shots back to back: cut only, no slate or handles"
|
||||
>
|
||||
<ListVideo className="h-3.5 w-3.5" />
|
||||
Play selection ({selected.size})
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeShot && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="font-mono text-sm text-white">{activeShot.shotCode}</span>
|
||||
@@ -267,7 +379,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* Player */}
|
||||
<div className="flex-1 min-w-0 bg-black flex flex-col">
|
||||
{activeShot ? (
|
||||
{sequenceMode && clips.length > 0 ? (
|
||||
<SequencePlayer clips={clips} onClipChange={handleClipChange} />
|
||||
) : activeShot ? (
|
||||
<ReviewPlayer
|
||||
key={activeShot.latestVersion.id}
|
||||
ref={playerRef}
|
||||
@@ -276,6 +390,8 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
fps={activeShot.latestVersion.fps}
|
||||
comments={[]}
|
||||
annotations={[]}
|
||||
handles={activeShot.latestVersion}
|
||||
hqVideoUrl={activeShot.latestVersion.hqFileUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-zinc-600">
|
||||
@@ -297,7 +413,26 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
<div className="px-3 py-2.5 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-zinc-400 uppercase tracking-wider">Shots</span>
|
||||
{shots.length > 0 && (
|
||||
<span className="text-xs text-zinc-600">{shots.length}</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{selected.size > 0 ? (
|
||||
<button
|
||||
onClick={() => updateSelection(new Set())}
|
||||
className="text-zinc-500 hover:text-zinc-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setGroupChecked(shots, true)}
|
||||
className="text-zinc-500 hover:text-zinc-200"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
)}
|
||||
<span className="text-zinc-600">
|
||||
{selected.size > 0 ? `${selected.size}/${shots.length}` : shots.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -320,16 +455,25 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
<div>
|
||||
{episodeGroups.map(([episode, episodeShots]) => (
|
||||
<div key={episode}>
|
||||
<div className="px-3 py-1.5 bg-zinc-900/80 sticky top-0 z-10 border-b border-zinc-800">
|
||||
<label className="px-3 py-1.5 bg-zinc-900/80 sticky top-0 z-10 border-b border-zinc-800 flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 accent-blue-500 cursor-pointer"
|
||||
checked={episodeShots.every((s) => selected.has(s.id))}
|
||||
onChange={(e) => setGroupChecked(episodeShots, e.target.checked)}
|
||||
title="Select this episode"
|
||||
/>
|
||||
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
|
||||
Ep {episode}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
{episodeShots.map((shot) => (
|
||||
<ShotThumbnail
|
||||
key={shot.id}
|
||||
shot={shot}
|
||||
isActive={activeShot?.id === shot.id}
|
||||
isChecked={selected.has(shot.id)}
|
||||
onToggle={(shift) => toggleChecked(shot, shift)}
|
||||
onClick={() => handleSelectShot(shot)}
|
||||
/>
|
||||
))}
|
||||
@@ -343,6 +487,8 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
key={shot.id}
|
||||
shot={shot}
|
||||
isActive={activeShot?.id === shot.id}
|
||||
isChecked={selected.has(shot.id)}
|
||||
onToggle={(shift) => toggleChecked(shot, shift)}
|
||||
onClick={() => handleSelectShot(shot)}
|
||||
/>
|
||||
))}
|
||||
@@ -358,25 +504,45 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
|
||||
function ShotThumbnail({
|
||||
shot,
|
||||
isActive,
|
||||
isChecked,
|
||||
onToggle,
|
||||
onClick,
|
||||
}: {
|
||||
shot: PlaylistShot;
|
||||
isActive: boolean;
|
||||
isChecked: boolean;
|
||||
onToggle: (shift: boolean) => void;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const thumb = shot.latestVersion.thumbnailUrl ?? shot.latestVersion.posterUrl ?? shot.thumbnailUrl;
|
||||
const ApprovalIcon = APPROVAL_ICONS[shot.latestVersion.approvalStatus] ?? Clock;
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onClick();
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-zinc-800/60 group",
|
||||
"w-full flex items-start gap-2.5 px-3 py-2.5 text-left transition-colors border-b border-zinc-800/60 group cursor-pointer",
|
||||
isActive
|
||||
? "bg-blue-500/10 border-l-2 border-l-blue-500"
|
||||
: "hover:bg-zinc-800/40 border-l-2 border-l-transparent"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle(e.shiftKey);
|
||||
}}
|
||||
className="mt-3 h-3.5 w-3.5 shrink-0 accent-blue-500 cursor-pointer"
|
||||
title="Add to playlist (shift-click for a range)"
|
||||
/>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 aspect-[2.39/1] rounded overflow-hidden bg-zinc-800 shrink-0 flex items-center justify-center">
|
||||
{thumb ? (
|
||||
@@ -408,6 +574,28 @@ function ShotThumbnail({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectionKey = (projectId: string) => `playlist:selection:${projectId}`;
|
||||
|
||||
/** The checked shots remembered for this project, minus any no longer listed. */
|
||||
function loadSelection(projectId: string, shots: PlaylistShot[]): Set<string> {
|
||||
try {
|
||||
const ids: unknown = JSON.parse(localStorage.getItem(selectionKey(projectId)) ?? "[]");
|
||||
if (!Array.isArray(ids)) return new Set();
|
||||
const listed = new Set(shots.map((s) => s.id));
|
||||
return new Set(ids.filter((id): id is string => typeof id === "string" && listed.has(id)));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveSelection(projectId: string, selected: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(selectionKey(projectId), JSON.stringify([...selected]));
|
||||
} catch {
|
||||
// Storage unavailable: the selection just isn't remembered.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TaskCard } from "@/components/tasks/TaskCard";
|
||||
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
|
||||
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sortShotsForList } from "@/lib/shot-order";
|
||||
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight, Calendar, Pencil, Check, X } from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
|
||||
@@ -147,13 +148,7 @@ export function ProjectTabsClient({
|
||||
// For episodic projects: group and sort shots by episode → scene → shotNumber
|
||||
const episodeGroups: [string, ShotWithDetails[]][] = projectType === "EPISODIC"
|
||||
? (() => {
|
||||
const sorted = [...shots].sort((a, b) => {
|
||||
const ea = a.episode ?? "";
|
||||
const eb = b.episode ?? "";
|
||||
if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true });
|
||||
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
|
||||
return a.shotNumber - b.shotNumber;
|
||||
});
|
||||
const sorted = sortShotsForList(shots, projectType);
|
||||
const map = new Map<string, ShotWithDetails[]>();
|
||||
for (const shot of sorted) {
|
||||
const key = shot.episode ?? "(No Episode)";
|
||||
@@ -167,13 +162,7 @@ export function ProjectTabsClient({
|
||||
// For standard projects: group by custom shot group when any exist
|
||||
const standardGroups: [string, ShotWithDetails[]][] = projectType === "STANDARD" && shots.some((s) => s.shotGroup)
|
||||
? (() => {
|
||||
const sorted = [...shots].sort((a, b) => {
|
||||
const ga = a.shotGroup?.name ?? "\uFFFF";
|
||||
const gb = b.shotGroup?.name ?? "\uFFFF";
|
||||
if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true });
|
||||
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
|
||||
return a.shotNumber - b.shotNumber;
|
||||
});
|
||||
const sorted = sortShotsForList(shots, projectType);
|
||||
const map = new Map<string, ShotWithDetails[]>();
|
||||
for (const shot of sorted) {
|
||||
const key = shot.shotGroup?.name ?? "(Ungrouped)";
|
||||
|
||||
@@ -69,7 +69,7 @@ export default async function KanbanPage({
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-[1800px] mx-auto">
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-[1800px] mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/projects" className="hover:text-foreground transition-colors">
|
||||
|
||||
@@ -123,7 +123,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
||||
const doneTasks = project.tasks.filter((t) => t.status === "DONE").length;
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
<div className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<Link href="/projects" className="hover:text-white transition-colors">
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
Pencil,
|
||||
Check,
|
||||
LayoutList,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
@@ -78,12 +80,47 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
|
||||
type ShotNavTarget = { id: string; shotCode: string } | null;
|
||||
type ShotNav = { prev: ShotNavTarget; next: ShotNavTarget; position: number; total: number };
|
||||
|
||||
function ShotNavLink({
|
||||
projectId,
|
||||
target,
|
||||
direction,
|
||||
}: {
|
||||
projectId: string;
|
||||
target: ShotNavTarget;
|
||||
direction: "prev" | "next";
|
||||
}) {
|
||||
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
|
||||
const label = direction === "prev" ? "Previous shot" : "Next shot";
|
||||
if (!target) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" disabled className="h-7 gap-1 px-2 font-mono text-xs">
|
||||
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
|
||||
<span className="hidden sm:inline">{direction === "prev" ? "Prev" : "Next"}</span>
|
||||
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button variant="outline" size="sm" asChild className="h-7 gap-1 px-2 font-mono text-xs">
|
||||
<Link href={`/projects/${projectId}/shots/${target.id}`} title={`${label}: ${target.shotCode}`}>
|
||||
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
|
||||
<span className="hidden sm:inline max-w-[10rem] truncate">{target.shotCode}</span>
|
||||
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShotDetailPage() {
|
||||
const params = useParams<{ id: string; shotId: string }>();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [shot, setShot] = useState<ShotWithDetails | null>(null);
|
||||
const [projectName, setProjectName] = useState<string>("");
|
||||
const [shotNav, setShotNav] = useState<ShotNav | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [canApprove, setCanApprove] = useState(false);
|
||||
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
|
||||
@@ -108,6 +145,7 @@ export default function ShotDetailPage() {
|
||||
const data = await res.json();
|
||||
setShot(data.shot);
|
||||
setProjectName(data.projectName ?? "");
|
||||
setShotNav(data.shotNav ?? null);
|
||||
setCanApprove(data.canApprove ?? false);
|
||||
setCanInternallyApprove(data.canInternallyApprove ?? false);
|
||||
setTasks(data.tasks ?? []);
|
||||
@@ -230,7 +268,7 @@ export default function ShotDetailPage() {
|
||||
const { Icon: StatusIcon } = statusCfg;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-[1400px] mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href="/projects" className="hover:text-foreground transition-colors">
|
||||
@@ -242,10 +280,23 @@ export default function ShotDetailPage() {
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground font-mono">{shot.shotCode}</span>
|
||||
|
||||
{/* Prev / next shot, in the project page's list order */}
|
||||
{shotNav && shotNav.total > 1 && (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ShotNavLink projectId={params.id} target={shotNav.prev} direction="prev" />
|
||||
<span className="px-1 text-xs tabular-nums">
|
||||
{shotNav.position} / {shotNav.total}
|
||||
</span>
|
||||
<ShotNavLink projectId={params.id} target={shotNav.next} direction="next" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-6">
|
||||
{/* Phones: back button, then a full-width thumbnail, then the details
|
||||
below it. From md: one row, as on desktop. */}
|
||||
<div className="flex flex-wrap md:flex-nowrap items-start gap-4 md:gap-6">
|
||||
{/* Thumbnail – cinema scope 2.39:1 */}
|
||||
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
|
||||
<Link href={`/projects/${params.id}`}>
|
||||
@@ -253,7 +304,7 @@ export default function ShotDetailPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
{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-full md:w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
|
||||
|
||||
<Image
|
||||
src={shotThumbnail(shot)!}
|
||||
@@ -263,10 +314,10 @@ export default function ShotDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
|
||||
<div className="space-y-2 min-w-0 w-full md:w-auto md:min-w-min">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
<h1 className="text-2xl font-bold font-mono break-all md:break-normal">{shot.shotCode}</h1>
|
||||
|
||||
{/* Shot version badge — click to edit for managers */}
|
||||
{canManage ? (
|
||||
@@ -424,7 +475,7 @@ export default function ShotDetailPage() {
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className="ml-auto shrink-0 flex items-center gap-2">
|
||||
<div className="ml-auto shrink-0 flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto">
|
||||
{/* Internal approval action */}
|
||||
{canInternallyApprove && shot.shotApprovalStatus === "PENDING" && (
|
||||
<Button
|
||||
@@ -510,7 +561,8 @@ export default function ShotDetailPage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div>
|
||||
<div className="flex border-b border-border mb-5">
|
||||
{/* Phones: five tabs are wider than the screen, so the bar scrolls sideways. */}
|
||||
<div className="flex border-b border-border mb-5 overflow-x-auto overflow-y-hidden md:overflow-visible [&>button]:shrink-0 [&>button]:whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => setActiveTab("tasks")}
|
||||
className={cn(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -8,6 +8,7 @@ import { ProjectCard } from "@/components/projects/ProjectCard";
|
||||
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
|
||||
import { Plus, Search, Loader2 } from "lucide-react";
|
||||
import { HIDE_ARCHIVED_KEY } from "@/components/settings/HideArchivedToggle";
|
||||
import { useScrollMemory } from "@/hooks/use-scroll-memory";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -43,22 +44,11 @@ export default function ProjectsPage() {
|
||||
|
||||
const visibleProjects = hideArchived ? projects.filter((p: any) => p.status !== "ARCHIVED") : projects;
|
||||
|
||||
const SCROLL_KEY = 'projects-scroll';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
const saved = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (saved) {
|
||||
sessionStorage.removeItem(SCROLL_KEY);
|
||||
requestAnimationFrame(() => window.scrollTo(0, parseInt(saved, 10)));
|
||||
}
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const saveScroll = () => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY));
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
useScrollMemory("projects", pageRef, !isLoading);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
<div ref={pageRef} className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
<div className="flex items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Projects</h1>
|
||||
@@ -104,9 +94,11 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
// auto-rows-fr keeps every row the same height, so a project with a
|
||||
// long description does not make its whole row taller than the rest.
|
||||
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{visibleProjects.map((project: any) => (
|
||||
<div key={project.id} onClick={saveScroll}>
|
||||
<div key={project.id} className="h-full">
|
||||
<ProjectCard project={project} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function MigrationPage() {
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<div className="p-4 md:p-6 max-w-2xl">
|
||||
<div className="flex items-center gap-2 text-sm text-red-400 rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{fetchError}
|
||||
@@ -200,7 +200,7 @@ export default function MigrationPage() {
|
||||
|
||||
if (!hetznerConfigured) {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<div className="p-4 md:p-6 max-w-2xl">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-400 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
Hetzner object storage is not configured. Set{" "}
|
||||
@@ -217,7 +217,7 @@ export default function MigrationPage() {
|
||||
const allFilteredSelected = filtered.length > 0 && filtered.every((f) => selected.has(f.key));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4 max-w-5xl">
|
||||
<div className="p-4 md:p-6 space-y-4 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function SettingsPage() {
|
||||
const hetznerConfig = isAdmin ? await getHetznerConfig() : null;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-2xl mx-auto">
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold">Account Settings</h1>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function StorageTestPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl space-y-6">
|
||||
<div className="p-4 md:p-6 max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Object Storage Playback Test</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { AlertCircle, Check, Loader2, MessageSquareReply } from "lucide-react";
|
||||
import { listUnreadShotComments, markShotCommentsRead } from "@/actions/comment-reads";
|
||||
import type { UnreadCommentItem } from "@/lib/comment-reads";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
/**
|
||||
* The "!" beside a shot code while it has comments this user has not marked
|
||||
* read. Opening it lists them; they stay flagged until "Mark read" is clicked —
|
||||
* opening the list or the shot is not enough.
|
||||
*/
|
||||
export function NewCommentsMarker({
|
||||
shotId,
|
||||
shotCode,
|
||||
count,
|
||||
latestAt,
|
||||
onRead,
|
||||
}: {
|
||||
shotId: string;
|
||||
shotCode: string;
|
||||
count: number;
|
||||
latestAt: string;
|
||||
onRead: (shotId: string) => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<UnreadCommentItem[] | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [marking, startMarking] = useTransition();
|
||||
|
||||
const load = async () => {
|
||||
setLoadError(false);
|
||||
try {
|
||||
setItems(await listUnreadShotComments(shotId));
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const markRead = () =>
|
||||
startMarking(async () => {
|
||||
// Read up to what was listed; with the list not loaded, up to what the
|
||||
// board showed. Anything newer stays flagged.
|
||||
const upTo = items?.[0]?.createdAt ?? latestAt;
|
||||
try {
|
||||
await markShotCommentsRead(shotId, upTo);
|
||||
setOpen(false);
|
||||
onRead(shotId);
|
||||
} catch {
|
||||
toast({ title: "Could not mark comments read", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) load();
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
|
||||
aria-label={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
|
||||
className="flex items-center justify-center h-4 min-w-4 rounded-full bg-orange-500 px-1 text-[10px] font-bold leading-none text-white hover:bg-orange-400 shrink-0"
|
||||
>
|
||||
!{count > 1 && <span className="ml-0.5 font-medium">{count}</span>}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="z-50 w-96 rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||
<span className="text-xs font-medium text-zinc-300">
|
||||
New on <span className="font-mono text-white">{shotCode}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={markRead}
|
||||
disabled={marking}
|
||||
className="flex items-center gap-1 rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{marking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
Mark read
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{loadError ? (
|
||||
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-red-400">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Could not load the comments.
|
||||
</p>
|
||||
) : items === null ? (
|
||||
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-zinc-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-3 py-4 text-xs text-zinc-500">
|
||||
Nothing new — the comments may have been deleted.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
<Link
|
||||
href={`/review/${item.versionId}`}
|
||||
className="block px-3 py-2 hover:bg-zinc-800/60"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500">
|
||||
{item.kind === "reply" && <MessageSquareReply className="h-3 w-3" />}
|
||||
<span className="text-zinc-300">{item.authorName}</span>
|
||||
{item.source === "WHATSAPP" && (
|
||||
<span className="rounded bg-green-500/15 px-1 text-green-400">client</span>
|
||||
)}
|
||||
<span className="font-mono">
|
||||
v{String(item.versionNumber).padStart(3, "0")} · f{item.frameNumber}
|
||||
</span>
|
||||
<span className="ml-auto">
|
||||
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-3 whitespace-pre-line text-xs text-zinc-200">
|
||||
{item.text}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import { useState, useTransition, useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useScrollMemory } from "@/hooks/use-scroll-memory";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -28,10 +30,14 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { updateShotNotes, bulkUpdateDueDates, bulkDeleteShots, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots, bulkSetManualDelivery, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
|
||||
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||
import { markProjectCommentsRead } from "@/actions/comment-reads";
|
||||
import { NewCommentsMarker } from "./NewCommentsMarker";
|
||||
import {
|
||||
Film,
|
||||
Clock,
|
||||
@@ -50,9 +56,13 @@ import {
|
||||
ChevronsUpDown,
|
||||
Clapperboard,
|
||||
Scaling,
|
||||
UserPlus,
|
||||
PackageCheck,
|
||||
ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { shotThumbnail } from "@/lib/shot-thumbnail";
|
||||
import type { ShotDeliverySummary } from "@/lib/render-pipeline/shot-delivery-summary";
|
||||
|
||||
type ShotRow = {
|
||||
id: string;
|
||||
@@ -69,11 +79,23 @@ type ShotRow = {
|
||||
description: string | null;
|
||||
isKeyShot: boolean;
|
||||
isScaled: boolean;
|
||||
manualDeliveredAt: Date | string | null;
|
||||
manualDeliveryNote: string | null;
|
||||
shotVersion: string | null;
|
||||
delivery: ShotDeliverySummary;
|
||||
/** Comments from others this user has not marked read; null = none. */
|
||||
unreadComments: { count: number; latestAt: string } | null;
|
||||
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||
shotGroup: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -87,10 +109,29 @@ interface ShotStatusClientProps {
|
||||
selectedProject: Project | null;
|
||||
shots: ShotRow[];
|
||||
episodeDueDates: { episode: string; dueDate: Date | string }[];
|
||||
artists: Artist[];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
const SCROLL_KEY = 'shot-status-scroll';
|
||||
function readStored(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A saved list of open sections, or null when none was saved (or it is unreadable). */
|
||||
function readStoredList(key: string): string[] | null {
|
||||
const raw = readStored(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
|
||||
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
|
||||
@@ -225,6 +266,257 @@ function BulkDueDateDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ── BulkAssignArtistDialog ────────────────────────────────────────────────────
|
||||
|
||||
const UNASSIGN = "__unassign__";
|
||||
|
||||
/**
|
||||
* Hand a selection of shots to one artist, or clear the assignment.
|
||||
*
|
||||
* Unassigning is offered as an explicit choice rather than a separate menu
|
||||
* entry — reassignment and hand-back are the same production move, and the
|
||||
* confirm button changes wording so nobody clears a batch by accident.
|
||||
*/
|
||||
function BulkAssignArtistDialog({
|
||||
open,
|
||||
onClose,
|
||||
onApply,
|
||||
artists,
|
||||
count,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (artistId: string | null) => Promise<void>;
|
||||
artists: Artist[];
|
||||
count: number;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setValue("");
|
||||
}, [open]);
|
||||
|
||||
const handleApply = () => {
|
||||
if (!value) return;
|
||||
startTransition(async () => {
|
||||
await onApply(value === UNASSIGN ? null : value);
|
||||
});
|
||||
};
|
||||
|
||||
const unassigning = value === UNASSIGN;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Artist</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-zinc-400 -mt-1">
|
||||
Applying to <span className="text-white font-medium">{count}</span>{" "}
|
||||
shot{count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<label className="text-xs font-medium text-zinc-400">Artist</label>
|
||||
<Select value={value} onValueChange={setValue}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select an artist..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{artists.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage src={a.image ?? undefined} />
|
||||
<AvatarFallback className="text-[9px] bg-zinc-700 text-zinc-300">
|
||||
{getInitials(a.name ?? a.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{a.name ?? a.email}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<SelectItem value={UNASSIGN}>
|
||||
<span className="text-zinc-400">Unassign</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-zinc-500 pt-1">
|
||||
This replaces whoever is currently on {count === 1 ? "the shot" : "these shots"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={!value || isPending}>
|
||||
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
{unassigning ? "Unassign" : "Assign"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ManualDeliveryDialog ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record shots as delivered outside the render pipeline — the ones sent by
|
||||
* hand before packages existed, which have no Export to put in a package.
|
||||
*
|
||||
* The date asked for is when the frames went out, not today: the delivery
|
||||
* countdown's weekly rate reads it, so a backfill stamped with today's date
|
||||
* would claim a week nobody had.
|
||||
*/
|
||||
function ManualDeliveryDialog({
|
||||
open,
|
||||
onClose,
|
||||
onApply,
|
||||
count,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (
|
||||
deliveredOn: string | null,
|
||||
note: string,
|
||||
evidence: { uploadUrl: string; sharedWith: string },
|
||||
) => Promise<void>;
|
||||
count: number;
|
||||
}) {
|
||||
const [date, setDate] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [uploadUrl, setUploadUrl] = useState("");
|
||||
const [sharedWith, setSharedWith] = useState("");
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDate("");
|
||||
setNote("");
|
||||
setUploadUrl("");
|
||||
setSharedWith("");
|
||||
setClearing(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleApply = () => {
|
||||
if (!clearing && !date) return;
|
||||
startTransition(async () => {
|
||||
await onApply(clearing ? null : date, note, { uploadUrl, sharedWith });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o && !isPending) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delivered Outside Pipeline</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-zinc-400 -mt-1">
|
||||
Applying to <span className="text-white font-medium">{count}</span>{" "}
|
||||
shot{count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 pt-1">
|
||||
<div className="space-y-2.5">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="manual-delivery-mode"
|
||||
checked={!clearing}
|
||||
onChange={() => setClearing(false)}
|
||||
className="accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-zinc-200">Mark as delivered</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="manual-delivery-mode"
|
||||
checked={clearing}
|
||||
onChange={() => setClearing(true)}
|
||||
className="accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-zinc-200">Clear manual delivery</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!clearing && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-400">Date the shots went out</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
max={format(new Date(), "yyyy-MM-dd")}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 [color-scheme:dark] focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-400">Upload link</label>
|
||||
<input
|
||||
type="url"
|
||||
value={uploadUrl}
|
||||
onChange={(e) => setUploadUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-400">Link sent to</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sharedWith}
|
||||
onChange={(e) => setSharedWith(e.target.value)}
|
||||
placeholder="Name, and where it was sent"
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Both are needed for shots on an open delivery request; a backfill of old deliveries can leave
|
||||
them blank.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-zinc-400">Note (optional)</label>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="e.g. Aspera upload, turnover 3"
|
||||
className="min-h-[56px] text-sm resize-none bg-zinc-900 border-zinc-700 focus:border-zinc-500 text-zinc-200 placeholder:text-zinc-600"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-zinc-500">
|
||||
For shots sent by hand before the render pipeline. They count as delivered on the
|
||||
dashboard; no package is created and nothing is sent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={(!clearing && !date) || isPending}>
|
||||
{isPending && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
{clearing ? "Clear" : "Mark delivered"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkDeleteDialog({
|
||||
open,
|
||||
onClose,
|
||||
@@ -567,6 +859,73 @@ function ScaledShotToggle({ shot }: { shot: ShotRow }) {
|
||||
);
|
||||
}
|
||||
|
||||
const PENDING_TONE = {
|
||||
progress: "text-blue-300",
|
||||
waiting: "text-amber-300",
|
||||
failed: "text-red-400",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* What last reached the client for this shot, with evidence — not what was
|
||||
* rendered or built — plus whatever newer is still on its way.
|
||||
*/
|
||||
function DeliveryCell({ delivery, shotVersion }: { delivery: ShotDeliverySummary; shotVersion: string | null }) {
|
||||
const { sent, review, pending } = delivery;
|
||||
if (!sent && !review && !pending) {
|
||||
return <span className="text-xs text-zinc-600 italic">Never sent</span>;
|
||||
}
|
||||
// The panel's version moving past what was delivered means a newer version
|
||||
// exists that the client has not had, even before it has been exported.
|
||||
const behind = sent?.version && shotVersion && sent.version !== shotVersion && !pending;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5 text-xs leading-tight">
|
||||
{sent ? (
|
||||
<div
|
||||
className="flex items-center gap-1 whitespace-nowrap text-emerald-300"
|
||||
// Who it went to stays in the tooltip: an email address swamps the column.
|
||||
title={[sent.label, sent.to && `sent to ${sent.to}`, sent.note].filter(Boolean).join(" · ")}
|
||||
>
|
||||
<PackageCheck className="h-3 w-3 shrink-0" />
|
||||
{sent.version && <span className="font-mono">{sent.version}</span>}
|
||||
<span className="text-zinc-300">{format(new Date(sent.at), "d MMM")}</span>
|
||||
{sent.via === "MANUAL" && <span className="text-zinc-500">· manual</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-zinc-600 italic">Not delivered</div>
|
||||
)}
|
||||
{pending && (
|
||||
<div className={cn("truncate", PENDING_TONE[pending.tone])} title={pending.label}>
|
||||
{pending.version && <span className="font-mono">{pending.version}</span>} {pending.label}
|
||||
</div>
|
||||
)}
|
||||
{behind && <div className="text-zinc-500">current is <span className="font-mono">{shotVersion}</span></div>}
|
||||
{review && (
|
||||
<div className="truncate text-zinc-500" title={review.label}>
|
||||
Review MOV <span className="font-mono">{review.version}</span> {format(new Date(review.at), "d MMM")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** On a section header: how many of its shots have new comments, so a
|
||||
* collapsed episode, scene or group still shows there is something to read. */
|
||||
function SectionUnreadBadge({ shots }: { shots: ShotRow[] }) {
|
||||
const flagged = shots.filter((s) => s.unreadComments).length;
|
||||
if (!flagged) return null;
|
||||
const label = `${flagged} shot${flagged !== 1 ? "s" : ""} with new comments`;
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="flex items-center gap-0.5 rounded-full bg-orange-500 px-1.5 h-4 text-[10px] font-bold leading-none text-white shrink-0"
|
||||
>
|
||||
!<span className="font-medium">{flagged}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ShotTable({
|
||||
shots,
|
||||
canManage,
|
||||
@@ -574,6 +933,7 @@ function ShotTable({
|
||||
selectedIds,
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
onCommentsRead,
|
||||
}: {
|
||||
shots: ShotRow[];
|
||||
canManage: boolean;
|
||||
@@ -581,6 +941,7 @@ function ShotTable({
|
||||
selectedIds: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onToggleAll: (ids: string[], checked: boolean) => void;
|
||||
onCommentsRead: (shotId: string) => void;
|
||||
}) {
|
||||
const allChecked = shots.length > 0 && shots.every((s) => selectedIds.has(s.id));
|
||||
const someChecked = shots.some((s) => selectedIds.has(s.id));
|
||||
@@ -601,7 +962,9 @@ function ShotTable({
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Thumb</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Shot</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Status</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-40">Artist</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Due Date</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-36">Last sent</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Notes</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">
|
||||
@@ -671,10 +1034,18 @@ function ShotTable({
|
||||
<Link
|
||||
href={`/projects/${projectId}/shots/${shot.id}`}
|
||||
className="font-mono text-sm text-white hover:text-blue-400 transition-colors"
|
||||
onClick={() => sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))}
|
||||
>
|
||||
{shot.shotCode}
|
||||
</Link>
|
||||
{shot.unreadComments && (
|
||||
<NewCommentsMarker
|
||||
shotId={shot.id}
|
||||
shotCode={shot.shotCode}
|
||||
count={shot.unreadComments.count}
|
||||
latestAt={shot.unreadComments.latestAt}
|
||||
onRead={onCommentsRead}
|
||||
/>
|
||||
)}
|
||||
{shot.shotVersion && (
|
||||
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
|
||||
)}
|
||||
@@ -700,6 +1071,25 @@ function ShotTable({
|
||||
</Badge>
|
||||
</td>
|
||||
|
||||
{/* Artist */}
|
||||
<td className="py-2 px-3">
|
||||
{shot.artist ? (
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
<AvatarImage src={shot.artist.image ?? undefined} />
|
||||
<AvatarFallback className="text-[9px] bg-zinc-700 text-zinc-300">
|
||||
{getInitials(shot.artist.name ?? shot.artist.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-zinc-300 truncate">
|
||||
{shot.artist.name ?? shot.artist.email}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 italic">Unassigned</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Due date */}
|
||||
<td className="py-2 px-3">
|
||||
{dueDate ? (
|
||||
@@ -717,6 +1107,11 @@ function ShotTable({
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Last delivery, and anything newer on its way */}
|
||||
<td className="py-2 px-3">
|
||||
<DeliveryCell delivery={shot.delivery} shotVersion={shot.shotVersion} />
|
||||
</td>
|
||||
|
||||
{/* Notes */}
|
||||
<td className="py-2 px-3 min-w-[220px]">
|
||||
<NotesCell shot={shot} canManage={canManage} />
|
||||
@@ -743,21 +1138,25 @@ export function ShotStatusClient({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
selectedProject,
|
||||
shots,
|
||||
shots: initialShots,
|
||||
episodeDueDates,
|
||||
artists,
|
||||
canManage,
|
||||
}: ShotStatusClientProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Restore scroll position when shots load
|
||||
useEffect(() => {
|
||||
const saved = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (saved) {
|
||||
sessionStorage.removeItem(SCROLL_KEY);
|
||||
requestAnimationFrame(() => window.scrollTo(0, parseInt(saved, 10)));
|
||||
}
|
||||
}, [shots]);
|
||||
// Shots marked read here drop their "!" at once, ahead of the refresh; the
|
||||
// refreshed props are authoritative, so the local set is cleared when they land.
|
||||
const [readShotIds, setReadShotIds] = useState<Set<string>>(new Set());
|
||||
useEffect(() => setReadShotIds(new Set()), [initialShots]);
|
||||
const shots = readShotIds.size
|
||||
? initialShots.map((s) => (readShotIds.has(s.id) ? { ...s, unreadComments: null } : s))
|
||||
: initialShots;
|
||||
const markShotReadLocally = (shotId: string) =>
|
||||
setReadShotIds((prev) => new Set(prev).add(shotId));
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const [markingAllRead, startMarkingAllRead] = useTransition();
|
||||
|
||||
// Restore last selected project if no project is currently selected
|
||||
useEffect(() => {
|
||||
@@ -775,36 +1174,56 @@ export function ShotStatusClient({
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
// Statuses picked from the summary badges; empty shows every shot.
|
||||
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load saved episode expand state when project changes
|
||||
// Which sections were open is restored before the scroll position: until
|
||||
// they are, the page is shorter and the saved offset would not fit.
|
||||
const [expandRestored, setExpandRestored] = useState(false);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
useScrollMemory(selectedProjectId ? `shotStatus:${selectedProjectId}` : null, pageRef, expandRestored);
|
||||
|
||||
// Load saved expand state and grouping when the project changes
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) return;
|
||||
try {
|
||||
const saved = localStorage.getItem(`shotStatus:${selectedProjectId}:episodeExpand`);
|
||||
setExpandedEpisodes(saved ? new Set(JSON.parse(saved) as string[]) : new Set());
|
||||
} catch {
|
||||
setExpandedEpisodes(new Set());
|
||||
}
|
||||
try {
|
||||
const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupMode`);
|
||||
setGroupMode((savedGroup as "flat" | "scene" | "group") ?? "flat");
|
||||
} catch {
|
||||
setGroupMode("flat");
|
||||
}
|
||||
setExpandRestored(false);
|
||||
setStatusFilter(new Set());
|
||||
setUnreadOnly(false);
|
||||
const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group";
|
||||
setGroupMode(mode);
|
||||
setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? []));
|
||||
// Scenes and groups open fully when their mode is first chosen, so with
|
||||
// nothing saved yet they come back that way rather than all collapsed.
|
||||
setExpandedScenes(
|
||||
new Set(readStoredList(`shotStatus:${selectedProjectId}:sceneExpand`) ?? shots.map((s) => s.scene))
|
||||
);
|
||||
setExpandedGroups(
|
||||
new Set(readStoredList(`shotStatus:${selectedProjectId}:groupExpand`) ?? shots.map((s) => s.shotGroup?.id ?? ""))
|
||||
);
|
||||
setExpandRestored(true);
|
||||
// Shots are read only for the defaults; a refresh after an edit must not
|
||||
// reset what the user has opened.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const saveExpanded = (kind: "episodeExpand" | "sceneExpand" | "groupExpand", open: Set<string>) => {
|
||||
if (!selectedProjectId) return;
|
||||
try {
|
||||
localStorage.setItem(`shotStatus:${selectedProjectId}:${kind}`, JSON.stringify([...open]));
|
||||
} catch {}
|
||||
};
|
||||
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
|
||||
const [manualDeliveryDialogOpen, setManualDeliveryDialogOpen] = useState(false);
|
||||
const [requestDialogOpen, setRequestDialogOpen] = useState(false);
|
||||
|
||||
const toggleEpisode = (ep: string) =>
|
||||
setExpandedEpisodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(ep) ? next.delete(ep) : next.add(ep);
|
||||
if (selectedProjectId) {
|
||||
try {
|
||||
localStorage.setItem(`shotStatus:${selectedProjectId}:episodeExpand`, JSON.stringify([...next]));
|
||||
} catch {}
|
||||
}
|
||||
saveExpanded("episodeExpand", next);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -812,6 +1231,7 @@ export function ShotStatusClient({
|
||||
setExpandedScenes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(sc) ? next.delete(sc) : next.add(sc);
|
||||
saveExpanded("sceneExpand", next);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -819,13 +1239,22 @@ export function ShotStatusClient({
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(g) ? next.delete(g) : next.add(g);
|
||||
saveExpanded("groupExpand", next);
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleGroupMode = (mode: "flat" | "scene" | "group") => {
|
||||
setGroupMode(mode);
|
||||
if (mode === "scene") setExpandedScenes(new Set(shots.map((s) => s.scene)));
|
||||
if (mode === "group") setExpandedGroups(new Set(shots.map((s) => s.shotGroup?.id ?? "")));
|
||||
if (mode === "scene") {
|
||||
const all = new Set(shots.map((s) => s.scene));
|
||||
setExpandedScenes(all);
|
||||
saveExpanded("sceneExpand", all);
|
||||
}
|
||||
if (mode === "group") {
|
||||
const all = new Set(shots.map((s) => s.shotGroup?.id ?? ""));
|
||||
setExpandedGroups(all);
|
||||
saveExpanded("groupExpand", all);
|
||||
}
|
||||
if (selectedProjectId) {
|
||||
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {}
|
||||
}
|
||||
@@ -854,6 +1283,52 @@ export function ShotStatusClient({
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set());
|
||||
|
||||
const toggleStatusFilter = (status: string) => {
|
||||
const next = new Set(statusFilter);
|
||||
next.has(status) ? next.delete(status) : next.add(status);
|
||||
setStatusFilter(next);
|
||||
// Bulk actions must never reach shots the filter has hidden.
|
||||
if (next.size > 0) {
|
||||
setSelectedIds((prev) => {
|
||||
const kept = new Set<string>();
|
||||
for (const s of shots)
|
||||
if (prev.has(s.id) && next.has(s.status) && (!unreadOnly || s.unreadComments)) kept.add(s.id);
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const visibleShots = shots.filter(
|
||||
(s) => (statusFilter.size === 0 || statusFilter.has(s.status)) && (!unreadOnly || s.unreadComments)
|
||||
);
|
||||
const unreadShotCount = shots.filter((s) => s.unreadComments).length;
|
||||
const isFiltered = statusFilter.size > 0 || unreadOnly;
|
||||
|
||||
const toggleUnreadOnly = () => {
|
||||
const next = !unreadOnly;
|
||||
setUnreadOnly(next);
|
||||
// Bulk actions must never reach shots the filter has hidden.
|
||||
if (next) {
|
||||
setSelectedIds((prev) => {
|
||||
const kept = new Set<string>();
|
||||
for (const s of shots) if (prev.has(s.id) && s.unreadComments) kept.add(s.id);
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () =>
|
||||
startMarkingAllRead(async () => {
|
||||
if (!selectedProjectId) return;
|
||||
try {
|
||||
await markProjectCommentsRead(selectedProjectId);
|
||||
setReadShotIds(new Set(shots.filter((s) => s.unreadComments).map((s) => s.id)));
|
||||
setUnreadOnly(false);
|
||||
} catch {
|
||||
toast({ title: "Could not mark comments read", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const handleBulkDueDate = async (date: string, includeTasks: boolean) => {
|
||||
try {
|
||||
const result = await bulkUpdateDueDates([...selectedIds], date, includeTasks);
|
||||
@@ -868,6 +1343,52 @@ export function ShotStatusClient({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAssignArtist = async (artistId: string | null) => {
|
||||
try {
|
||||
const result = await bulkAssignArtist([...selectedIds], artistId);
|
||||
toast({
|
||||
title: result.artistName
|
||||
? `${result.updated} shot${result.updated !== 1 ? "s" : ""} assigned to ${result.artistName}`
|
||||
: `${result.updated} shot${result.updated !== 1 ? "s" : ""} unassigned`,
|
||||
});
|
||||
setAssignDialogOpen(false);
|
||||
clearSelection();
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to assign artist",
|
||||
description: e instanceof Error ? e.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualDelivery = async (
|
||||
deliveredOn: string | null,
|
||||
note: string,
|
||||
evidence: { uploadUrl: string; sharedWith: string },
|
||||
) => {
|
||||
try {
|
||||
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note, evidence);
|
||||
if (!result.success) {
|
||||
// The dialog stays open so the missing link and recipient can be added.
|
||||
toast({ title: "Not marked delivered", description: result.error, variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const n =`${result.updated} shot${result.updated !== 1 ? "s" : ""}`;
|
||||
toast({ title: deliveredOn ? `${n} marked delivered` : `Manual delivery cleared on ${n}` });
|
||||
setManualDeliveryDialogOpen(false);
|
||||
clearSelection();
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to update delivery",
|
||||
description: e instanceof Error ? e.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
const result = await bulkDeleteShots([...selectedIds]);
|
||||
@@ -893,7 +1414,7 @@ export function ShotStatusClient({
|
||||
const episodeGroups: [string, ShotRow[]][] = isEpisodic
|
||||
? (() => {
|
||||
const map = new Map<string, ShotRow[]>();
|
||||
for (const shot of shots) {
|
||||
for (const shot of visibleShots) {
|
||||
const key = shot.episode ?? "(No Episode)";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(shot);
|
||||
@@ -905,7 +1426,7 @@ export function ShotStatusClient({
|
||||
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
|
||||
? (() => {
|
||||
const map = new Map<string, ShotRow[]>();
|
||||
for (const shot of shots) {
|
||||
for (const shot of visibleShots) {
|
||||
const key = shot.scene || "(No Scene)";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(shot);
|
||||
@@ -917,7 +1438,7 @@ export function ShotStatusClient({
|
||||
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
|
||||
? (() => {
|
||||
const map = new Map<string, [string, ShotRow[]]>();
|
||||
for (const shot of shots) {
|
||||
for (const shot of visibleShots) {
|
||||
const id = shot.shotGroup?.id ?? "";
|
||||
const name = shot.shotGroup?.name ?? "(No Group)";
|
||||
if (!map.has(id)) map.set(id, [name, []]);
|
||||
@@ -942,7 +1463,7 @@ export function ShotStatusClient({
|
||||
const selectionCount = selectedIds.size;
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
<div ref={pageRef} className="p-4 md:p-8 space-y-6 max-w-[1600px] mx-auto">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Shot Status</h1>
|
||||
@@ -950,10 +1471,12 @@ export function ShotStatusClient({
|
||||
</div>
|
||||
|
||||
{/* Project selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Phones: the picker fills the row and "Group by" wraps onto its own
|
||||
line. From md: one row, as on desktop. */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm text-zinc-400 shrink-0">Project:</label>
|
||||
<Select value={selectedProjectId ?? ""} onValueChange={handleProjectChange}>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectTrigger className="flex-1 min-w-0 md:flex-none md:w-72">
|
||||
<SelectValue placeholder="Select a project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -966,7 +1489,7 @@ export function ShotStatusClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedProject && !isEpisodic && (
|
||||
<>
|
||||
<div className="flex w-full md:w-auto items-center gap-3">
|
||||
<span className="text-sm text-zinc-500">Group by:</span>
|
||||
{(["flat", "scene", "group"] as const).map((mode) => (
|
||||
<button
|
||||
@@ -982,7 +1505,7 @@ export function ShotStatusClient({
|
||||
{mode === "flat" ? "None" : mode === "scene" ? "Scene" : "Group"}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1008,16 +1531,78 @@ export function ShotStatusClient({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectionCount === 0 ? (
|
||||
<>
|
||||
<span className="text-xs text-zinc-500 self-center">{shots.length} shots total</span>
|
||||
<span className="text-xs text-zinc-500 self-center">
|
||||
{isFiltered ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
|
||||
</span>
|
||||
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
|
||||
const count = statusCounts[key] ?? 0;
|
||||
if (!count) return null;
|
||||
const active = statusFilter.has(key);
|
||||
return (
|
||||
<Badge key={key} className={cn("gap-1 text-xs", cfg.color)}>
|
||||
{count} {cfg.label}
|
||||
</Badge>
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => toggleStatusFilter(key)}
|
||||
aria-pressed={active}
|
||||
title={active ? `Stop filtering by ${cfg.label}` : `Show only ${cfg.label}`}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Badge
|
||||
className={cn(
|
||||
"gap-1 text-xs cursor-pointer",
|
||||
cfg.color,
|
||||
statusFilter.size > 0 && !active && "opacity-40 hover:opacity-70",
|
||||
active && "ring-1 ring-current"
|
||||
)}
|
||||
>
|
||||
{count} {cfg.label}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{unreadShotCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleUnreadOnly}
|
||||
aria-pressed={unreadOnly}
|
||||
title={unreadOnly ? "Show all shots" : "Show only shots with new comments"}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Badge
|
||||
className={cn(
|
||||
"gap-1 text-xs cursor-pointer bg-orange-500/15 text-orange-300 border-orange-500/30",
|
||||
unreadOnly && "ring-1 ring-current"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">!</span>
|
||||
{unreadShotCount} with new comments
|
||||
</Badge>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markingAllRead}
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{markingAllRead ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||
Mark all read
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{isFiltered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter(new Set());
|
||||
setUnreadOnly(false);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Clear filter
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-blue-500/30 bg-blue-500/10 px-4 py-2">
|
||||
@@ -1040,10 +1625,22 @@ export function ShotStatusClient({
|
||||
<CalendarDays className="h-3.5 w-3.5 mr-2" />
|
||||
Change Due Date
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAssignDialogOpen(true)}>
|
||||
<UserPlus className="h-3.5 w-3.5 mr-2" />
|
||||
Assign Artist
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setMovReviewDialogOpen(true)}>
|
||||
<Clapperboard className="h-3.5 w-3.5 mr-2" />
|
||||
Build MOV Review Package
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setRequestDialogOpen(true)}>
|
||||
<ClipboardList className="h-3.5 w-3.5 mr-2" />
|
||||
Log Delivery Request
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setManualDeliveryDialogOpen(true)}>
|
||||
<PackageCheck className="h-3.5 w-3.5 mr-2" />
|
||||
Delivered Outside Pipeline
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
@@ -1100,6 +1697,7 @@ export function ShotStatusClient({
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<SectionUnreadBadge shots={episodeShots} />
|
||||
{epDueDate && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
@@ -1119,6 +1717,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1158,6 +1757,7 @@ export function ShotStatusClient({
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<SectionUnreadBadge shots={sceneShots} />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
@@ -1168,6 +1768,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1207,6 +1808,7 @@ export function ShotStatusClient({
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<SectionUnreadBadge shots={groupShots} />
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
@@ -1217,6 +1819,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1228,11 +1831,12 @@ export function ShotStatusClient({
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<ShotTable
|
||||
shots={shots}
|
||||
shots={visibleShots}
|
||||
canManage={canManage}
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1249,6 +1853,33 @@ export function ShotStatusClient({
|
||||
count={selectionCount}
|
||||
/>
|
||||
|
||||
{/* Bulk artist assignment dialog */}
|
||||
<BulkAssignArtistDialog
|
||||
open={assignDialogOpen}
|
||||
onClose={() => setAssignDialogOpen(false)}
|
||||
onApply={handleBulkAssignArtist}
|
||||
artists={artists}
|
||||
count={selectionCount}
|
||||
/>
|
||||
|
||||
{/* Manual (pre-pipeline) delivery dialog */}
|
||||
<ManualDeliveryDialog
|
||||
open={manualDeliveryDialogOpen}
|
||||
onClose={() => setManualDeliveryDialogOpen(false)}
|
||||
onApply={handleManualDelivery}
|
||||
count={selectionCount}
|
||||
/>
|
||||
|
||||
{/* A client asked for the selected shots */}
|
||||
{selectedProjectId && (
|
||||
<LogRequestDialog
|
||||
open={requestDialogOpen}
|
||||
onClose={() => setRequestDialogOpen(false)}
|
||||
onLogged={clearSelection}
|
||||
preset={{ projectId: selectedProjectId, shotIds: [...selectedIds] }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk delete dialog */}
|
||||
<BulkDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { db } from "@/lib/db";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ShotStatusClient } from "./ShotStatusClient";
|
||||
import { summariseShotDelivery } from "@/lib/render-pipeline/shot-delivery-summary";
|
||||
import { getUnreadCommentSummaries, type UnreadCommentSummary } from "@/lib/comment-reads";
|
||||
|
||||
async function getProjects() {
|
||||
return db.project.findMany({
|
||||
@@ -19,8 +21,17 @@ async function getEpisodeDueDatesForProject(projectId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Everyone a shot can be handed to — clients and deactivated accounts excluded. */
|
||||
async function getAssignableArtists() {
|
||||
return db.user.findMany({
|
||||
where: { isActive: true, role: { not: "CLIENT" } },
|
||||
select: { id: true, name: true, email: true, image: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async function getShotsForProject(projectId: string) {
|
||||
return db.shot.findMany({
|
||||
const shots = await db.shot.findMany({
|
||||
where: { projectId },
|
||||
orderBy: [
|
||||
{ episode: { sort: "asc", nulls: "last" } },
|
||||
@@ -42,13 +53,57 @@ async function getShotsForProject(projectId: string) {
|
||||
description: true,
|
||||
isKeyShot: true,
|
||||
isScaled: true,
|
||||
manualDeliveredAt: true,
|
||||
manualDeliveryNote: true,
|
||||
manualDeliverySharedWith: true,
|
||||
// The shot-level version the panel burns into overlays and queueExport
|
||||
// renders under — not the count of review Versions uploaded.
|
||||
shotVersion: true,
|
||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||
shotGroup: { select: { id: true, name: true } },
|
||||
// Every export, for the packages each went into; the newest live one
|
||||
// is what is on its way.
|
||||
exports: {
|
||||
orderBy: [{ versionNumber: "desc" }, { createdAt: "desc" }],
|
||||
select: {
|
||||
versionString: true,
|
||||
status: true,
|
||||
deliveryItems: {
|
||||
select: {
|
||||
versionString: true,
|
||||
package: {
|
||||
select: {
|
||||
kind: true,
|
||||
status: true,
|
||||
packageNumber: true,
|
||||
createdAt: true,
|
||||
builtAt: true,
|
||||
uploadedAt: true,
|
||||
deliveredAt: true,
|
||||
sharedWith: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return shots.map(({ exports, ...shot }) => ({
|
||||
...shot,
|
||||
delivery: summariseShotDelivery({
|
||||
packages: exports.flatMap((e) =>
|
||||
e.deliveryItems.map((item) => ({ ...item.package, versionString: item.versionString }))
|
||||
),
|
||||
manual: {
|
||||
deliveredAt: shot.manualDeliveredAt,
|
||||
sharedWith: shot.manualDeliverySharedWith,
|
||||
note: shot.manualDeliveryNote,
|
||||
},
|
||||
latestExport: exports.find((e) => e.status !== "CANCELLED" && e.status !== "ARCHIVED") ?? null,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function ShotStatusPage({
|
||||
@@ -62,11 +117,14 @@ export default async function ShotStatusPage({
|
||||
const { projectId } = await searchParams;
|
||||
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||
|
||||
const [projects, shots, episodeDueDates] = await Promise.all([
|
||||
const [projects, shotRows, episodeDueDates, artists, unread] = await Promise.all([
|
||||
getProjects(),
|
||||
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
|
||||
projectId ? getEpisodeDueDatesForProject(projectId) : Promise.resolve([]),
|
||||
canManage ? getAssignableArtists() : Promise.resolve([]),
|
||||
projectId ? getUnreadCommentSummaries(session.user.id, projectId) : Promise.resolve({} as Record<string, UnreadCommentSummary>),
|
||||
]);
|
||||
const shots = shotRows.map((shot) => ({ ...shot, unreadComments: unread[shot.id] ?? null }));
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
|
||||
|
||||
@@ -77,6 +135,7 @@ export default async function ShotStatusPage({
|
||||
selectedProject={selectedProject}
|
||||
shots={shots as any}
|
||||
episodeDueDates={episodeDueDates}
|
||||
artists={artists}
|
||||
canManage={canManage}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -110,7 +110,7 @@ export function TasksPageClient({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default async function UsersPage() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="p-4 md:p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center shrink-0">
|
||||
<Users className="h-5 w-5 text-amber-400" />
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { PLATE_EXTS, parsePlateFilename } from "@/lib/batch-upload-parser";
|
||||
|
||||
export type PlatePreviewItemStatus =
|
||||
| "match" // shot found → a new footage plate will be added
|
||||
| "duplicate" // the shot already has a plate with this exact filename
|
||||
| "no-match" // no shot in this project matches the filename
|
||||
| "unsupported"; // extension is not an accepted video format
|
||||
|
||||
export interface PlatePreviewItem {
|
||||
fileName: string;
|
||||
shotCode: string | null;
|
||||
shotId: string | null;
|
||||
/** Label the new plate will be created with */
|
||||
label: string;
|
||||
status: PlatePreviewItemStatus;
|
||||
/** Number of plates the matched shot already has */
|
||||
existingPlateCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/batch-upload/plates
|
||||
* Body: { projectId: string; fileNames: string[] }
|
||||
*
|
||||
* Previews which shot each footage plate file will be attached to, derived
|
||||
* from the filename. e.g. "DH2_VFX_058_120_REF.mov" → shot "DH2_VFX_058_120"
|
||||
* with the label "REF". Nothing is uploaded.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { projectId, fileNames } = body as { projectId?: unknown; fileNames?: unknown };
|
||||
|
||||
if (!projectId || typeof projectId !== "string" || !Array.isArray(fileNames)) {
|
||||
return NextResponse.json(
|
||||
{ error: "projectId and fileNames[] are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const shots = await db.shot.findMany({
|
||||
where: { projectId },
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
footagePlates: { select: { fileName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const shotCodes = shots.map((s) => s.shotCode);
|
||||
const shotByCode = new Map(shots.map((s) => [s.shotCode, s]));
|
||||
|
||||
// Several files in one batch can land on the same shot, so keep a running
|
||||
// count per shot to number the "Plate N" fallback labels correctly.
|
||||
const allocated = new Map<string, number>();
|
||||
|
||||
const items: PlatePreviewItem[] = fileNames.map((fileName: string) => {
|
||||
const parsed = parsePlateFilename(fileName, shotCodes);
|
||||
|
||||
if (!PLATE_EXTS.has(parsed.ext)) {
|
||||
return {
|
||||
fileName,
|
||||
shotCode: null,
|
||||
shotId: null,
|
||||
label: "",
|
||||
status: "unsupported" as const,
|
||||
existingPlateCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const shot = parsed.shotCode ? shotByCode.get(parsed.shotCode) ?? null : null;
|
||||
if (!shot) {
|
||||
return {
|
||||
fileName,
|
||||
shotCode: null,
|
||||
shotId: null,
|
||||
label: "",
|
||||
status: "no-match" as const,
|
||||
existingPlateCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const existingPlateCount = shot.footagePlates.length;
|
||||
|
||||
// Re-running a batch shouldn't silently pile up identical plates.
|
||||
const isDuplicate = shot.footagePlates.some(
|
||||
(p) => p.fileName.toLowerCase() === fileName.toLowerCase()
|
||||
);
|
||||
if (isDuplicate) {
|
||||
return {
|
||||
fileName,
|
||||
shotCode: shot.shotCode,
|
||||
shotId: shot.id,
|
||||
label: parsed.label,
|
||||
status: "duplicate" as const,
|
||||
existingPlateCount,
|
||||
};
|
||||
}
|
||||
|
||||
const takenSoFar = allocated.get(shot.id) ?? 0;
|
||||
allocated.set(shot.id, takenSoFar + 1);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
shotCode: shot.shotCode,
|
||||
shotId: shot.id,
|
||||
label: parsed.label || `Plate ${existingPlateCount + takenSoFar + 1}`,
|
||||
status: "match" as const,
|
||||
existingPlateCount,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
/**
|
||||
* POST /api/batch-upload/plates/upload
|
||||
* Body: { projectId, shotId, key, fileName, fileSize?, label? }
|
||||
*
|
||||
* Commits a footage plate whose bytes were already uploaded straight to
|
||||
* Hetzner via POST /api/upload/presign, so large .mov plates never pass
|
||||
* through this server or the reverse proxy.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { projectId, shotId, key, fileName, fileSize, label } = body as {
|
||||
projectId?: unknown;
|
||||
shotId?: unknown;
|
||||
key?: unknown;
|
||||
fileName?: unknown;
|
||||
fileSize?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof projectId !== "string" ||
|
||||
typeof shotId !== "string" ||
|
||||
typeof key !== "string" ||
|
||||
typeof fileName !== "string" ||
|
||||
!projectId ||
|
||||
!shotId ||
|
||||
!key ||
|
||||
!fileName
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "projectId, shotId, key and fileName are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// The key must be one this app handed out via /api/upload/presign.
|
||||
if (!key.startsWith("videos/")) {
|
||||
return NextResponse.json({ error: "Invalid storage key" }, { status: 400 });
|
||||
}
|
||||
|
||||
const shot = await db.shot.findFirst({
|
||||
where: { id: shotId, projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||
|
||||
const last = await db.footagePlate.findFirst({
|
||||
where: { shotId },
|
||||
orderBy: { sortOrder: "desc" },
|
||||
select: { sortOrder: true },
|
||||
});
|
||||
|
||||
const size = typeof fileSize === "number" && Number.isFinite(fileSize) && fileSize > 0
|
||||
? BigInt(Math.round(fileSize))
|
||||
: null;
|
||||
|
||||
const plate = await db.footagePlate.create({
|
||||
data: {
|
||||
shotId,
|
||||
fileUrl: `/api/files/${key}`,
|
||||
fileKey: key,
|
||||
fileName,
|
||||
fileSize: size,
|
||||
label: typeof label === "string" ? label.trim().slice(0, 100) : "",
|
||||
sortOrder: (last?.sortOrder ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
revalidatePath(`/projects/${projectId}/shots/${shotId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
plateId: plate.id,
|
||||
label: plate.label,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { slackNotifyNewFeedback } from "@/lib/slack";
|
||||
import { notifyCommentsAdded } from "@/lib/notifications";
|
||||
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
|
||||
|
||||
export async function POST(
|
||||
@@ -28,8 +29,8 @@ export async function POST(
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
|
||||
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
|
||||
shot: { select: { projectId: true, shotCode: true, artistId: true, project: { select: { slackWebhook: true } } } },
|
||||
task: { select: { projectId: true, title: true, assignedArtistId: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
|
||||
},
|
||||
});
|
||||
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||
@@ -70,6 +71,18 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
await notifyCommentsAdded({
|
||||
notes: [{
|
||||
versionId,
|
||||
shotCode,
|
||||
frameNumber,
|
||||
artistIds: [version.shot?.artistId, version.task?.assignedArtistId],
|
||||
}],
|
||||
authorId: user.id,
|
||||
authorName: user.name ?? user.email,
|
||||
origin: "client",
|
||||
}).catch((err) => console.error("[client comment] notify failed", err));
|
||||
|
||||
// canEdit mirrors the GET shape — a note you just wrote is always your own.
|
||||
return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export async function GET(
|
||||
clientCode: true,
|
||||
episode: true,
|
||||
sequence: true,
|
||||
scene: true,
|
||||
description: true,
|
||||
status: true,
|
||||
shotApprovalStatus: true,
|
||||
@@ -124,9 +125,13 @@ export async function GET(
|
||||
// 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 }) => ({
|
||||
// The scene is our numbering too, and an alias does not have to share it
|
||||
// (SNV's "SNV_101_0463_CP_VFX" is our scene 001), so an aliased shot goes
|
||||
// out without one rather than with a number the client never used.
|
||||
shots: shots.map(({ generatedThumbnailUrl, clientCode, scene, ...shot }) => ({
|
||||
...shot,
|
||||
shotCode: clientShotCode({ ...shot, clientCode }),
|
||||
scene: clientShotCode({ ...shot, clientCode }) === shot.shotCode ? scene || null : null,
|
||||
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
|
||||
tasks: shot.tasks.map((task) => ({
|
||||
...task,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { validateReviewToken } from "@/lib/review-auth";
|
||||
import { generateHetznerDownloadUrl } from "@/lib/storage";
|
||||
import { clientHighResFilename } from "@/lib/shot-code";
|
||||
|
||||
/**
|
||||
* GET /api/client/[token]/versions/[versionId]/highres/download
|
||||
* The version's ProRes, for a client reviewer: same visibility rule as the
|
||||
* version itself (GET ../route.ts), and named with the client's shot code.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string; versionId: string }> }
|
||||
) {
|
||||
const { token, versionId } = await params;
|
||||
|
||||
const result = await validateReviewToken(token, req);
|
||||
if (result.type !== "ok") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
select: {
|
||||
highResKey: true,
|
||||
highResFilename: true,
|
||||
isClientVisible: true,
|
||||
shot: { select: { projectId: true, shotCode: true, clientCode: true } },
|
||||
task: {
|
||||
select: {
|
||||
projectId: true,
|
||||
shot: { select: { shotCode: true, clientCode: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||
if (!version || projectId !== result.session.projectId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
// Task-only versions need explicit sharing, as in the version route
|
||||
if (!version.shot && !version.isClientVisible) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (!version.highResKey) {
|
||||
return NextResponse.json({ error: "No high-res file available" }, { status: 404 });
|
||||
}
|
||||
|
||||
const shot = version.shot ?? version.task?.shot ?? null;
|
||||
const url = await generateHetznerDownloadUrl(
|
||||
version.highResKey,
|
||||
clientHighResFilename(version.highResFilename ?? "high-res.mov", shot)
|
||||
);
|
||||
return NextResponse.json({ url });
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { validateReviewToken, findReviewUser, ownsComment } from "@/lib/review-auth";
|
||||
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code";
|
||||
import { clientHighResFilename, clientShotCode, clientTaskTitle } from "@/lib/shot-code";
|
||||
|
||||
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
|
||||
export async function GET(
|
||||
@@ -95,13 +95,21 @@ export async function GET(
|
||||
// The shot the title and breadcrumb are drawn from — either relation.
|
||||
const namingShot = version.shot ?? ((version.task as any)?.shot ?? null);
|
||||
|
||||
// High Res: this version's own ProRes (uploaded by the render worker) wins;
|
||||
// the shot's manually uploaded file is the fallback for older versions.
|
||||
const versionHighRes = !!version.highResKey;
|
||||
const serializedVersion = {
|
||||
...version,
|
||||
fileSize: version.fileSize?.toString() ?? null,
|
||||
highResKey: undefined, // never expose a storage key
|
||||
highResSize: version.highResSize?.toString() ?? null,
|
||||
// Top-level high-res fields so the client works for both shot and task versions
|
||||
hasHighRes: !!(highResShot?.highResKey),
|
||||
highResShotId: highResShot?.highResKey ? (highResShot.id ?? null) : null,
|
||||
highResFilename: highResShot?.highResFilename ?? null,
|
||||
hasHighRes: versionHighRes || !!(highResShot?.highResKey),
|
||||
highResSource: versionHighRes ? "version" : highResShot?.highResKey ? "shot" : null,
|
||||
highResShotId: !versionHighRes && highResShot?.highResKey ? (highResShot.id ?? null) : null,
|
||||
highResFilename: versionHighRes
|
||||
? clientHighResFilename(version.highResFilename ?? "high-res.mov", namingShot)
|
||||
: highResShot?.highResFilename ?? null,
|
||||
// Expose only whether a high-res file exists on the shot relation, never the storage key
|
||||
shot: version.shot
|
||||
? {
|
||||
|
||||
@@ -2,20 +2,30 @@ 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";
|
||||
import { recordShareSchema } from "@/lib/render-pipeline/delivery-handoff";
|
||||
|
||||
// ── 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.
|
||||
// The upload link has been sent to the client. Takes who it was sent to, and
|
||||
// moves every packaged export to DELIVERED — the end of the pipeline.
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ packageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const { packageId } = await params;
|
||||
const result = await markDelivered(packageId, user.id);
|
||||
|
||||
const parsed = recordShareSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await markDelivered(packageId, parsed.data, user.id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { markUploaded } from "@/lib/render-pipeline/deliveries";
|
||||
import { recordUploadSchema } from "@/lib/render-pipeline/delivery-handoff";
|
||||
|
||||
// ── POST /api/deliveries/{id}/uploaded ───────────────────────────────────────
|
||||
//
|
||||
// The folder is on the client's portal. Records where, so the package shows as
|
||||
// waiting to be shared rather than waiting to be uploaded.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ packageId: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const { packageId } = await params;
|
||||
|
||||
const parsed = recordUploadSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await markUploaded(packageId, parsed.data, user.id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
|
||||
import { cancelDeliveryRequest } from "@/lib/render-pipeline/delivery-requests";
|
||||
import { cancelRequestSchema } from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
// ── POST /api/delivery-requests/{id}/cancel ──────────────────────────────────
|
||||
//
|
||||
// The only way a request leaves the awaiting list without being delivered, and
|
||||
// it has to say why.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ requestId: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
const { requestId } = await params;
|
||||
|
||||
const parsed = cancelRequestSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await cancelDeliveryRequest(requestId, parsed.data.reason, user.id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||
import { getRequestOptions } from "@/lib/render-pipeline/delivery-requests";
|
||||
|
||||
// ── GET /api/delivery-requests/options?projectId= ────────────────────────────
|
||||
//
|
||||
// The episodes a request can be logged against.
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requirePipelineUser();
|
||||
const projectId = new URL(req.url).searchParams.get("projectId");
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(await getRequestOptions(projectId));
|
||||
} 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, requirePipelineUser } from "@/lib/render-pipeline/session-auth";
|
||||
import { createDeliveryRequest, listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
|
||||
import { createRequestSchema } from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
// ── GET /api/delivery-requests · POST /api/delivery-requests ─────────────────
|
||||
//
|
||||
// What clients have asked for, and how much of each ask has gone out.
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requirePipelineUser();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const requests = await listDeliveryRequests({
|
||||
projectId: searchParams.get("projectId") ?? undefined,
|
||||
openOnly: searchParams.get("open") === "1",
|
||||
});
|
||||
return NextResponse.json({ requests });
|
||||
} catch (err) {
|
||||
const { body, status } = toErrorResponse(err);
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requirePipelineAdmin();
|
||||
|
||||
const parsed = createRequestSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createDeliveryRequest(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,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
|
||||
import { isArtifactKey } from "@/lib/render-pipeline/artifact-kinds";
|
||||
import { db } from "@/lib/db";
|
||||
import { abortHetznerMultipartUpload, completeHetznerMultipartUpload } from "@/lib/storage";
|
||||
|
||||
// ── POST /api/ext/render/jobs/{jobId}/artifact-complete (E20b) ───────────────
|
||||
//
|
||||
// Closes a multipart upload started by artifact-presign: stitches the parts
|
||||
// (by part number + ETag) into the final object, or with `abort: true` throws
|
||||
// the stored parts away after the worker gave up.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { jobId } = await params;
|
||||
let body: {
|
||||
machineId?: string;
|
||||
key?: string;
|
||||
uploadId?: string;
|
||||
parts?: { partNumber?: number; etag?: string }[];
|
||||
abort?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
if (!body.machineId) throw new PipelineError(400, "machineId is required");
|
||||
if (!body.key || !isArtifactKey(body.key)) throw new PipelineError(422, "key is not a worker artifact key");
|
||||
if (!body.uploadId) throw new PipelineError(400, "uploadId is required");
|
||||
|
||||
const job = await db.renderJob.findUnique({ where: { id: jobId }, select: { machineId: true } });
|
||||
if (!job) throw new PipelineError(404, "Render job not found");
|
||||
if (job.machineId !== body.machineId) {
|
||||
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
|
||||
}
|
||||
|
||||
if (body.abort) {
|
||||
await abortHetznerMultipartUpload(body.key, body.uploadId);
|
||||
return NextResponse.json({ ok: true, aborted: true });
|
||||
}
|
||||
|
||||
const parts = (body.parts ?? []).filter(
|
||||
(p): p is { partNumber: number; etag: string } =>
|
||||
typeof p.partNumber === "number" && typeof p.etag === "string" && p.etag.length > 0
|
||||
);
|
||||
if (parts.length === 0) throw new PipelineError(422, "parts are required");
|
||||
|
||||
await completeHetznerMultipartUpload(body.key, body.uploadId, parts);
|
||||
return NextResponse.json({ ok: true, key: body.key, url: `/api/files/${body.key}` });
|
||||
} catch (err) {
|
||||
const { body: errBody, status } = toErrorResponse(err);
|
||||
return NextResponse.json(errBody, { status });
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,29 @@ import { randomUUID } from "crypto";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse, PipelineError } from "@/lib/render-pipeline/errors";
|
||||
import { db } from "@/lib/db";
|
||||
import { generateHetznerPresignedUploadUrl, sanitizeFileName } from "@/lib/storage";
|
||||
import { ARTIFACT_KIND_FOLDERS } from "@/lib/render-pipeline/artifact-kinds";
|
||||
import {
|
||||
createHetznerMultipartUpload,
|
||||
generateHetznerPresignedUploadUrl,
|
||||
sanitizeFileName,
|
||||
} from "@/lib/storage";
|
||||
|
||||
// ── POST /api/ext/render/jobs/{jobId}/artifact-presign (E20) ─────────────────
|
||||
//
|
||||
// Presigned upload URL for worker artifacts. Kinds map to the existing object
|
||||
// storage folder layout: preview → videos/, thumbnail → image/,
|
||||
// metadata/log → renders/.
|
||||
// metadata/log → renders/, highres (the delivery MOV) → highres/ beside the
|
||||
// manually uploaded High Res files.
|
||||
//
|
||||
// `multipart: true` + `size` (worker ≥ 1.7.2) returns an S3 multipart upload
|
||||
// instead — an uploadId and one presigned URL per part — which the worker
|
||||
// fills in parallel and closes with artifact-complete (E20b). A single PUT
|
||||
// from the studio is capped near 0.1 MB/s; parallel parts scale with the
|
||||
// number of connections. Older workers never ask and get the single PUT.
|
||||
|
||||
const KIND_FOLDERS: Record<string, string> = {
|
||||
preview: "videos",
|
||||
thumbnail: "image",
|
||||
metadata: "renders",
|
||||
log: "renders",
|
||||
};
|
||||
const KIND_FOLDERS = ARTIFACT_KIND_FOLDERS;
|
||||
const MIN_PART = 8 * 1024 * 1024;
|
||||
const MAX_PARTS = 10000; // S3 limit
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
@@ -26,7 +35,14 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { jobId } = await params;
|
||||
let body: { machineId?: string; kind?: string; fileName?: string; contentType?: string };
|
||||
let body: {
|
||||
machineId?: string;
|
||||
kind?: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
multipart?: boolean;
|
||||
size?: number;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
@@ -48,6 +64,14 @@ export async function POST(
|
||||
}
|
||||
|
||||
const key = `${folder}/${randomUUID()}-${sanitizeFileName(body.fileName)}`;
|
||||
|
||||
if (body.multipart && typeof body.size === "number" && body.size > MIN_PART) {
|
||||
const partSize = Math.max(MIN_PART, Math.ceil(body.size / MAX_PARTS));
|
||||
const partCount = Math.ceil(body.size / partSize);
|
||||
const { uploadId, partUrls } = await createHetznerMultipartUpload(key, body.contentType, partCount);
|
||||
return NextResponse.json({ key, url: `/api/files/${key}`, uploadId, partSize, partUrls });
|
||||
}
|
||||
|
||||
const presignedUrl = await generateHetznerPresignedUploadUrl(key, body.contentType);
|
||||
return NextResponse.json({ presignedUrl, key, url: `/api/files/${key}` });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth";
|
||||
import { toErrorResponse } from "@/lib/render-pipeline/errors";
|
||||
import { attachHighRes, type AttachHighResInput } from "@/lib/render-pipeline/preview";
|
||||
|
||||
// ── POST /api/ext/render/jobs/{jobId}/attach-highres (E13b) ──────────────────
|
||||
//
|
||||
// The preview's ProRes MOV, uploaded in the background after finalize (worker
|
||||
// ≥ 1.7.1). Attaches it to the version as its High Res download, or records on
|
||||
// the export timeline that the upload failed.
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
if (!isExtAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { jobId } = await params;
|
||||
|
||||
let body: AttachHighResInput & { machineId?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
if (!body.machineId) {
|
||||
return NextResponse.json({ error: "machineId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await attachHighRes(jobId, body.machineId, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const { body: errBody, status } = toErrorResponse(err);
|
||||
return NextResponse.json(errBody, { status });
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,12 @@ export async function GET(req: NextRequest) {
|
||||
thumbnailUrl: true,
|
||||
posterUrl: true,
|
||||
fps: true,
|
||||
duration: true,
|
||||
frameCount: true,
|
||||
hqFileUrl: true,
|
||||
handleHead: true,
|
||||
handleTail: true,
|
||||
pictureStartFrame: true,
|
||||
approvalStatus: true,
|
||||
mimeType: true,
|
||||
createdAt: true,
|
||||
@@ -85,6 +91,12 @@ export async function GET(req: NextRequest) {
|
||||
thumbnailUrl: latest.thumbnailUrl,
|
||||
posterUrl: latest.posterUrl,
|
||||
fps: latest.fps,
|
||||
duration: latest.duration,
|
||||
frameCount: latest.frameCount,
|
||||
hqFileUrl: latest.hqFileUrl,
|
||||
handleHead: latest.handleHead,
|
||||
handleTail: latest.handleTail,
|
||||
pictureStartFrame: latest.pictureStartFrame,
|
||||
approvalStatus: latest.approvalStatus,
|
||||
taskTitle: latest.taskTitle,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { getProjectPosters } from "@/lib/project-poster";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await auth();
|
||||
@@ -53,9 +54,13 @@ export async function GET(req: Request) {
|
||||
statsMap.set(row.projectId, existing);
|
||||
}
|
||||
|
||||
// One still per project for the card art, chosen from the shots themselves.
|
||||
const posters = await getProjectPosters(projects.map((p) => p.id));
|
||||
|
||||
const projectsWithStats = projects.map((p) => ({
|
||||
...p,
|
||||
shotStats: statsMap.get(p.id) ?? { total: 0, approved: 0, inProgress: 0 },
|
||||
poster: posters.get(p.id) ?? null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ projects: projectsWithStats });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { listExports } from "@/lib/render-pipeline/exports";
|
||||
// ── GET /api/render/queue (E21) ──────────────────────────────────────────────
|
||||
//
|
||||
// Exports list for the Render Queue page. Optional filters: projectId,
|
||||
// episode, status; standard pagination. Defaults to all statuses so the queue
|
||||
// episode, status, q (shot/client code search); standard pagination. Defaults to all statuses so the queue
|
||||
// page can show history too — the UI filters live states client-side.
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -19,6 +19,7 @@ export async function GET(req: NextRequest) {
|
||||
projectId: searchParams.get("projectId") ?? undefined,
|
||||
episode: searchParams.get("episode") ?? undefined,
|
||||
shotId: searchParams.get("shotId") ?? undefined,
|
||||
search: searchParams.get("q")?.slice(0, 100) || undefined,
|
||||
status:
|
||||
status && (Object.values(ExportStatus) as string[]).includes(status)
|
||||
? (status as ExportStatus)
|
||||
|
||||
@@ -73,6 +73,40 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a review link: push its expiry `expiresInDays` from now and turn
|
||||
* it back on. The token is unchanged, so a URL the client already has works
|
||||
* again rather than a new one having to be sent.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const expiresInDays = Number(body.expiresInDays ?? 30);
|
||||
if (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 365) {
|
||||
return NextResponse.json({ error: "expiresInDays must be 1–365" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await db.reviewSession.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!existing) return NextResponse.json({ error: "Review link not found" }, { status: 404 });
|
||||
|
||||
const reviewSession = await db.reviewSession.update({
|
||||
where: { id },
|
||||
data: { expiresAt: addDays(new Date(), expiresInDays), isActive: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ session: reviewSession });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { sortShotsForList } from "@/lib/shot-order";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
@@ -38,9 +39,35 @@ export async function GET(
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: shot.projectId },
|
||||
select: { name: true },
|
||||
select: {
|
||||
name: true,
|
||||
projectType: true,
|
||||
shots: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
episode: true,
|
||||
scene: true,
|
||||
shotNumber: true,
|
||||
shotGroup: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Neighbours in the project page's shot list order, for prev/next navigation
|
||||
const orderedShots = project ? sortShotsForList(project.shots, project.projectType) : [];
|
||||
const shotIndex = orderedShots.findIndex((s) => s.id === shotId);
|
||||
const neighbour = (i: number) =>
|
||||
shotIndex >= 0 && orderedShots[i] ? { id: orderedShots[i].id, shotCode: orderedShots[i].shotCode } : null;
|
||||
const shotNav = {
|
||||
prev: neighbour(shotIndex - 1),
|
||||
next: neighbour(shotIndex + 1),
|
||||
position: shotIndex + 1,
|
||||
total: orderedShots.length,
|
||||
};
|
||||
|
||||
const [tasks, artists] = await Promise.all([
|
||||
db.task.findMany({
|
||||
where: { shotId },
|
||||
@@ -82,7 +109,7 @@ export async function GET(
|
||||
session.user.role as string
|
||||
);
|
||||
|
||||
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
|
||||
// Serialize BigInt fields (fileSize, highResSize) so JSON.stringify doesn't throw
|
||||
const shotSerialized = {
|
||||
...shot,
|
||||
footagePlates: shot.footagePlates.map((p) => ({
|
||||
@@ -92,12 +119,14 @@ export async function GET(
|
||||
versions: shot.versions.map((v) => ({
|
||||
...v,
|
||||
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
|
||||
highResSize: v.highResSize != null ? v.highResSize.toString() : null,
|
||||
})),
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
shot: shotSerialized,
|
||||
projectName: project?.name ?? "",
|
||||
shotNav,
|
||||
canApprove,
|
||||
canInternallyApprove: ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string),
|
||||
tasks,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { generateHetznerDownloadUrl } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* GET /api/versions/[versionId]/highres/download — short-lived presigned URL
|
||||
* for this version's ProRes (the delivery MOV the render worker uploaded).
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ versionId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { versionId } = await params;
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
select: { highResKey: true, highResFilename: true },
|
||||
});
|
||||
|
||||
if (!version?.highResKey) {
|
||||
return NextResponse.json({ error: "No high-res file available" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = await generateHetznerDownloadUrl(
|
||||
version.highResKey,
|
||||
version.highResFilename ?? "high-res.mov"
|
||||
);
|
||||
return NextResponse.json({ url });
|
||||
}
|
||||
+244
-159
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useScrollMemory } from '@/hooks/use-scroll-memory';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Check,
|
||||
Search,
|
||||
X,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Montserrat } from 'next/font/google';
|
||||
@@ -51,6 +53,7 @@ interface ClientShot {
|
||||
shotCode: string;
|
||||
episode: string | null;
|
||||
sequence: string | null;
|
||||
scene: string | null;
|
||||
description: string | null;
|
||||
status: string;
|
||||
thumbnailUrl: string | null;
|
||||
@@ -127,7 +130,6 @@ const TASK_TYPE_LABELS: Record<string, string> = {
|
||||
GENERAL: 'Task',
|
||||
};
|
||||
|
||||
const SCROLL_KEY = 'client-portal-scroll';
|
||||
const QUEUE_KEY = 'client-portal-queue';
|
||||
const STATE_KEY = 'client-portal-state';
|
||||
const PREFS_KEY_PREFIX = 'client-portal-prefs-';
|
||||
@@ -136,6 +138,26 @@ function getLatestVersion(task: ClientTask): ClientVersion | undefined {
|
||||
return task.versions[0];
|
||||
}
|
||||
|
||||
const NO_SCENE = '__none__';
|
||||
|
||||
/** Shots split into scenes in scene order, with unscened shots last. */
|
||||
function groupShotsByScene<T extends ClientShot>(shots: T[]): [string, T[]][] {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const shot of shots) {
|
||||
const key = shot.scene || NO_SCENE;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(shot);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => {
|
||||
if (a === NO_SCENE) return 1;
|
||||
if (b === NO_SCENE) return -1;
|
||||
return a.localeCompare(b, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Collapse key for a scene, kept in the same set as the episodes. */
|
||||
const sceneKey = (episode: string, scene: string) => `${episode}::${scene}`;
|
||||
|
||||
export default function ClientPortalPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -152,8 +174,14 @@ export default function ClientPortalPage({
|
||||
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [sortMode, setSortMode] = useState<'episode' | 'recent'>('episode');
|
||||
const [groupByScene, setGroupByScene] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [episodeFilter, setEpisodeFilter] = useState<string | null>(null);
|
||||
// Filters and collapsed episodes set the page height, so the scroll
|
||||
// position is restored only once they are back.
|
||||
const [viewRestored, setViewRestored] = useState(false);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
useScrollMemory(token ? `clientPortal:${token}` : null, pageRef, viewRestored);
|
||||
|
||||
const toggleEpisode = (ep: string) => {
|
||||
setCollapsedEpisodes((prev) => {
|
||||
@@ -189,9 +217,10 @@ export default function ClientPortalPage({
|
||||
if (savedState) {
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
try {
|
||||
const { statusFilter: sf, sortMode: sm, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(savedState);
|
||||
const { statusFilter: sf, sortMode: sm, groupByScene: gs, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(savedState);
|
||||
if (sf !== undefined) setStatusFilter(sf);
|
||||
if (sm !== undefined) setSortMode(sm);
|
||||
if (gs !== undefined) setGroupByScene(gs);
|
||||
if (sq !== undefined) setSearchQuery(sq ?? '');
|
||||
if (ef !== undefined) setEpisodeFilter(ef);
|
||||
if (ce !== undefined) setCollapsedEpisodes(new Set(ce));
|
||||
@@ -200,22 +229,17 @@ export default function ClientPortalPage({
|
||||
try {
|
||||
const prefs = localStorage.getItem(PREFS_KEY_PREFIX + token);
|
||||
if (prefs) {
|
||||
const { statusFilter: sf, sortMode: sm, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(prefs);
|
||||
const { statusFilter: sf, sortMode: sm, groupByScene: gs, searchQuery: sq, episodeFilter: ef, collapsedEpisodes: ce } = JSON.parse(prefs);
|
||||
if (sf !== undefined) setStatusFilter(sf);
|
||||
if (sm !== undefined) setSortMode(sm);
|
||||
if (gs !== undefined) setGroupByScene(gs);
|
||||
if (sq !== undefined) setSearchQuery(sq ?? '');
|
||||
if (ef !== undefined) setEpisodeFilter(ef);
|
||||
if (ce !== undefined) setCollapsedEpisodes(new Set(ce));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const saved = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (saved) {
|
||||
sessionStorage.removeItem(SCROLL_KEY);
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, parseInt(saved, 10));
|
||||
});
|
||||
}
|
||||
setViewRestored(true);
|
||||
}
|
||||
}, [loading, token]);
|
||||
|
||||
@@ -227,13 +251,14 @@ export default function ClientPortalPage({
|
||||
JSON.stringify({
|
||||
statusFilter,
|
||||
sortMode,
|
||||
groupByScene,
|
||||
searchQuery,
|
||||
episodeFilter,
|
||||
collapsedEpisodes: Array.from(collapsedEpisodes),
|
||||
}),
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}, [token, statusFilter, sortMode, searchQuery, episodeFilter, collapsedEpisodes, loading]);
|
||||
}, [token, statusFilter, sortMode, groupByScene, searchQuery, episodeFilter, collapsedEpisodes, loading]);
|
||||
|
||||
const loadProject = (t: string) => {
|
||||
setLoading(true);
|
||||
@@ -310,6 +335,8 @@ export default function ClientPortalPage({
|
||||
|
||||
// Group shots by episode; null episode → "Shots"
|
||||
const isEpisodic = shots.some((s) => s.episode != null);
|
||||
const hasScenes = shots.some((s) => s.scene);
|
||||
const showScenes = groupByScene && hasScenes;
|
||||
const shotsByEpisode = shots.reduce<Record<string, ClientShot[]>>(
|
||||
(acc, shot) => {
|
||||
const key = shot.episode ?? 'Shots';
|
||||
@@ -410,10 +437,11 @@ export default function ClientPortalPage({
|
||||
Object.entries(shotsByEpisode)
|
||||
.filter(([episode]) => episodeFilter === null || episodeFilter === episode)
|
||||
.forEach(([, epShots]) => {
|
||||
epShots
|
||||
const visible = epShots
|
||||
.map((shot) => ({ ...shot, tasks: shot.tasks.filter(taskMatchesFilter) }))
|
||||
.filter((shot) => shot.tasks.length > 0)
|
||||
.filter(shotMatchesSearch)
|
||||
.filter(shotMatchesSearch);
|
||||
(showScenes ? groupShotsByScene(visible).flatMap(([, sc]) => sc) : visible)
|
||||
.forEach((shot) => {
|
||||
shot.tasks.forEach((task) => {
|
||||
const ver = getLatestVersion(task);
|
||||
@@ -429,13 +457,13 @@ export default function ClientPortalPage({
|
||||
};
|
||||
|
||||
const handleReviewClick = () => {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY));
|
||||
sessionStorage.setItem(QUEUE_KEY, JSON.stringify(getReviewQueue()));
|
||||
sessionStorage.setItem(
|
||||
STATE_KEY,
|
||||
JSON.stringify({
|
||||
statusFilter,
|
||||
sortMode,
|
||||
groupByScene,
|
||||
searchQuery,
|
||||
episodeFilter,
|
||||
collapsedEpisodes: Array.from(collapsedEpisodes),
|
||||
@@ -443,8 +471,149 @@ export default function ClientPortalPage({
|
||||
);
|
||||
};
|
||||
|
||||
// Approved / needs changes / awaiting counts for an episode or scene header.
|
||||
const statusPills = (tasks: ClientTask[]) => {
|
||||
const nApproved = tasks.filter(
|
||||
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
|
||||
).length;
|
||||
const nChanges = tasks.filter((t) =>
|
||||
['REJECTED', 'NEEDS_CHANGES'].includes(
|
||||
getLatestVersion(t)?.approvalStatus ?? '',
|
||||
),
|
||||
).length;
|
||||
const nPending = tasks.length - nApproved - nChanges;
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{nApproved > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-900/40 border border-emerald-800/40 text-emerald-400 text-xs">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{nApproved}
|
||||
</span>
|
||||
)}
|
||||
{nChanges > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-900/30 border border-red-800/30 text-red-400 text-xs">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{nChanges}
|
||||
</span>
|
||||
)}
|
||||
{nPending > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-900/20 border border-amber-800/20 text-amber-400 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
{nPending}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderShot = (shot: ClientShot) => (
|
||||
<div key={shot.id} className="px-5 py-4 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{shot.thumbnailUrl && (
|
||||
<div className="relative flex-shrink-0 w-32 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
alt={shot.shotCode}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 group/shotcode">
|
||||
<p className="font-mono text-sm font-semibold text-zinc-300">
|
||||
{shot.shotCode}
|
||||
{shot.description && (
|
||||
<span className="font-sans font-normal text-zinc-500 ml-2">
|
||||
{shot.description}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => copyToClipboard(shot.shotCode, `shot-${shot.id}`)}
|
||||
className="shrink-0 p-0.5 rounded opacity-0 group-hover/shotcode:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
title="Copy shot code"
|
||||
>
|
||||
{copiedId === `shot-${shot.id}` ? (
|
||||
<Check className="h-3 w-3 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
|
||||
{shot.tasks.map((task) => {
|
||||
const ver = getLatestVersion(task);
|
||||
const approvalKey =
|
||||
ver?.approvalStatus ?? 'PENDING_REVIEW';
|
||||
const approval =
|
||||
APPROVAL_STYLES[approvalKey] ??
|
||||
APPROVAL_STYLES.PENDING_REVIEW;
|
||||
const ApprovalIcon = approval.Icon;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||
onClick={handleReviewClick}
|
||||
className={cn(
|
||||
'flex flex-wrap sm:flex-nowrap items-center gap-x-4 gap-y-2 p-3 sm:p-4 rounded-xl border transition-all group',
|
||||
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||
!ver && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 w-full sm:w-auto sm:flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-white min-w-0 break-words">
|
||||
{task.title}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
|
||||
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
title="Copy task name"
|
||||
>
|
||||
{copiedId === `task-${task.id}` ? (
|
||||
<Check className="h-3 w-3 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||
</p>
|
||||
</div>
|
||||
{ver ? (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{ver.createdAt && (
|
||||
<span className="text-xs text-zinc-600 hidden sm:block">
|
||||
{formatDate(ver.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
|
||||
approval.className,
|
||||
)}
|
||||
>
|
||||
<ApprovalIcon className="h-3 w-3" />
|
||||
{approval.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 shrink-0">
|
||||
No versions yet
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors ml-auto sm:ml-0" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white">
|
||||
<div ref={pageRef} className="min-h-screen bg-zinc-950 text-white">
|
||||
<header className="border-b border-zinc-800 bg-zinc-900">
|
||||
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
@@ -454,7 +623,7 @@ export default function ClientPortalPage({
|
||||
</div>
|
||||
|
||||
<div className={montserrat.className}>
|
||||
<span className="block text-2xl font-light text-white leading-none">
|
||||
<span className="block text-2xl font-light text-white leading-none whitespace-nowrap">
|
||||
TWO TALES
|
||||
</span>
|
||||
|
||||
@@ -464,7 +633,7 @@ export default function ClientPortalPage({
|
||||
</div>
|
||||
</div>
|
||||
{sessionLabel && (
|
||||
<span className="text-sm text-zinc-400">{sessionLabel}</span>
|
||||
<span className="ml-4 min-w-0 truncate text-right text-sm text-zinc-400" title={sessionLabel}>{sessionLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
@@ -620,6 +789,20 @@ export default function ClientPortalPage({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasScenes && sortMode === 'episode' && (
|
||||
<button
|
||||
onClick={() => setGroupByScene((prev) => !prev)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
|
||||
groupByScene
|
||||
? 'bg-amber-500/10 border-amber-500/30 text-amber-400'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300',
|
||||
)}
|
||||
>
|
||||
<Layers className="h-3 w-3" />
|
||||
{groupByScene ? 'Grouped by Scene' : 'Group by Scene'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSortMode((prev) => (prev === 'episode' ? 'recent' : 'episode'))
|
||||
@@ -687,7 +870,7 @@ export default function ClientPortalPage({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 group/itemlabel">
|
||||
<div className="flex min-w-0 items-center gap-1.5 group/itemlabel">
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{item.label}
|
||||
{item.description && (
|
||||
@@ -698,7 +881,7 @@ export default function ClientPortalPage({
|
||||
</p>
|
||||
<button
|
||||
onClick={() => copyToClipboard(item.label, `label-${task.id}`)}
|
||||
className="shrink-0 p-0.5 rounded opacity-0 group-hover/itemlabel:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover/itemlabel:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
title="Copy name"
|
||||
>
|
||||
{copiedId === `label-${task.id}` ? (
|
||||
@@ -709,7 +892,7 @@ export default function ClientPortalPage({
|
||||
</button>
|
||||
</div>
|
||||
{ver?.createdAt && (
|
||||
<p className="ml-auto text-xs text-zinc-600">
|
||||
<p className="ml-auto shrink-0 whitespace-nowrap text-xs text-zinc-600">
|
||||
{formatDate(ver.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
@@ -718,19 +901,19 @@ export default function ClientPortalPage({
|
||||
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||
onClick={handleReviewClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
|
||||
'flex flex-wrap sm:flex-nowrap items-center gap-x-4 gap-y-2 p-3 sm:p-4 rounded-xl border transition-all group',
|
||||
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||
!ver && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 w-full sm:w-auto sm:flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-white">
|
||||
<p className="text-sm font-medium text-white min-w-0 break-words">
|
||||
{task.title}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
|
||||
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
title="Copy task name"
|
||||
>
|
||||
{copiedId === `task-${task.id}` ? (
|
||||
@@ -759,7 +942,7 @@ export default function ClientPortalPage({
|
||||
No versions yet
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors ml-auto sm:ml-0" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -780,15 +963,6 @@ export default function ClientPortalPage({
|
||||
|
||||
const isCollapsed = collapsedEpisodes.has(episode);
|
||||
const epTasks = filteredEpShots.flatMap((s) => s.tasks);
|
||||
const epApproved = epTasks.filter(
|
||||
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
|
||||
).length;
|
||||
const epChanges = epTasks.filter((t) =>
|
||||
['REJECTED', 'NEEDS_CHANGES'].includes(
|
||||
getLatestVersion(t)?.approvalStatus ?? '',
|
||||
),
|
||||
).length;
|
||||
const epPending = epTasks.length - epApproved - epChanges;
|
||||
|
||||
return (
|
||||
<div key={episode} className="rounded-xl border border-zinc-800 overflow-hidden">
|
||||
@@ -812,137 +986,48 @@ export default function ClientPortalPage({
|
||||
{filteredEpShots.length === 1 ? 'shot' : 'shots'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Per-episode progress pills */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{epApproved > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-900/40 border border-emerald-800/40 text-emerald-400 text-xs">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{epApproved}
|
||||
</span>
|
||||
)}
|
||||
{epChanges > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-900/30 border border-red-800/30 text-red-400 text-xs">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{epChanges}
|
||||
</span>
|
||||
)}
|
||||
{epPending > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-900/20 border border-amber-800/20 text-amber-400 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
{epPending}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{statusPills(epTasks)}
|
||||
</button>
|
||||
|
||||
{/* Collapsible shot list */}
|
||||
{!isCollapsed && (
|
||||
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40">
|
||||
{filteredEpShots.map((shot) => (
|
||||
<div key={shot.id} className="px-5 py-4 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{shot.thumbnailUrl && (
|
||||
<div className="relative flex-shrink-0 w-32 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
|
||||
<Image
|
||||
src={shot.thumbnailUrl}
|
||||
alt={shot.shotCode}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 group/shotcode">
|
||||
<p className="font-mono text-sm font-semibold text-zinc-300">
|
||||
{shot.shotCode}
|
||||
{shot.description && (
|
||||
<span className="font-sans font-normal text-zinc-500 ml-2">
|
||||
{shot.description}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => copyToClipboard(shot.shotCode, `shot-${shot.id}`)}
|
||||
className="shrink-0 p-0.5 rounded opacity-0 group-hover/shotcode:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
title="Copy shot code"
|
||||
>
|
||||
{copiedId === `shot-${shot.id}` ? (
|
||||
<Check className="h-3 w-3 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
|
||||
{shot.tasks.map((task) => {
|
||||
const ver = getLatestVersion(task);
|
||||
const approvalKey =
|
||||
ver?.approvalStatus ?? 'PENDING_REVIEW';
|
||||
const approval =
|
||||
APPROVAL_STYLES[approvalKey] ??
|
||||
APPROVAL_STYLES.PENDING_REVIEW;
|
||||
const ApprovalIcon = approval.Icon;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
|
||||
onClick={handleReviewClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
|
||||
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
|
||||
!ver && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
{showScenes
|
||||
? groupShotsByScene(filteredEpShots).map(([scene, sceneShots]) => {
|
||||
const key = sceneKey(episode, scene);
|
||||
const sceneCollapsed = collapsedEpisodes.has(key);
|
||||
return (
|
||||
<div key={key}>
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-5 py-2.5 bg-zinc-900/60 hover:bg-zinc-800/60 transition-colors text-left"
|
||||
onClick={() => toggleEpisode(key)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-zinc-500 shrink-0 transition-transform duration-200',
|
||||
sceneCollapsed && '-rotate-90',
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-white">
|
||||
{task.title}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
|
||||
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
title="Copy task name"
|
||||
>
|
||||
{copiedId === `task-${task.id}` ? (
|
||||
<Check className="h-3 w-3 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{TASK_TYPE_LABELS[task.type] ?? task.type}
|
||||
</p>
|
||||
</div>
|
||||
{ver ? (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{ver.createdAt && (
|
||||
<span className="text-xs text-zinc-600 hidden sm:block">
|
||||
{formatDate(ver.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
|
||||
approval.className,
|
||||
)}
|
||||
>
|
||||
<ApprovalIcon className="h-3 w-3" />
|
||||
{approval.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 shrink-0">
|
||||
No versions yet
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-zinc-400">
|
||||
{scene === NO_SCENE ? 'No scene' : `Scene ${scene}`}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<span className="ml-3 text-xs text-zinc-600">
|
||||
{sceneShots.length}{' '}
|
||||
{sceneShots.length === 1 ? 'shot' : 'shots'}
|
||||
</span>
|
||||
</div>
|
||||
{statusPills(sceneShots.flatMap((s) => s.tasks))}
|
||||
</button>
|
||||
{!sceneCollapsed && (
|
||||
<div className="divide-y divide-zinc-800/60 border-t border-zinc-800/60">
|
||||
{sceneShots.map(renderShot)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: filteredEpShots.map(renderShot)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1007,7 +1092,7 @@ export default function ClientPortalPage({
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); copyToClipboard(task.title, `task-${task.id}`); }}
|
||||
className="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
className="hidden sm:block shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-300 transition-all"
|
||||
title="Copy task name"
|
||||
>
|
||||
{copiedId === `task-${task.id}` ? (
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
Clock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Check,
|
||||
Download,
|
||||
@@ -36,6 +38,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { ReviewPasswordGate } from "@/components/clients/ReviewPasswordGate";
|
||||
import { HandleBadge } from "@/components/player/HandleBadge";
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
@@ -43,6 +46,8 @@ interface Comment {
|
||||
timestamp: number;
|
||||
text: string;
|
||||
isResolved: boolean;
|
||||
/** "WHATSAPP" = client feedback sent by message and logged by the studio */
|
||||
source?: string | null;
|
||||
createdAt: string;
|
||||
/** This reviewer wrote it, so they can change or remove it. Set by the API. */
|
||||
canEdit?: boolean;
|
||||
@@ -56,6 +61,14 @@ interface Version {
|
||||
fileUrl: string;
|
||||
fps: number;
|
||||
duration: number | null;
|
||||
frameCount?: number | null;
|
||||
/** High quality review MP4 — the player's quality switch. */
|
||||
hqFileUrl?: string | null;
|
||||
/** Where the High Res download comes from: this version's ProRes, or the shot's legacy file. */
|
||||
highResSource?: "version" | "shot" | null;
|
||||
handleHead?: number | null;
|
||||
handleTail?: number | null;
|
||||
pictureStartFrame?: number | null;
|
||||
approvalStatus: string;
|
||||
notes: string | null;
|
||||
// Top-level high-res flags (normalised from either shot or task.shot)
|
||||
@@ -274,10 +287,17 @@ export default function ClientReviewPage({
|
||||
|
||||
const handleDownloadHighRes = async () => {
|
||||
const shotId = version?.highResShotId ?? version?.shot?.id;
|
||||
if (!shotId || !token) return;
|
||||
if (!version || !token) return;
|
||||
const endpoint =
|
||||
version.highResSource === "version"
|
||||
? `/api/client/${token}/versions/${version.id}/highres/download`
|
||||
: shotId
|
||||
? `/api/client/${token}/shots/${shotId}/highres/download`
|
||||
: null;
|
||||
if (!endpoint) return;
|
||||
setDownloadingHighRes(true);
|
||||
try {
|
||||
const res = await fetch(`/api/client/${token}/shots/${shotId}/highres/download`);
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) throw new Error("Could not get download link");
|
||||
const { url } = await res.json();
|
||||
// Open the presigned URL — the Content-Disposition header forces a download
|
||||
@@ -357,9 +377,11 @@ export default function ClientReviewPage({
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-zinc-950 text-white overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
|
||||
{/* Phones: back + shot + prev/next on the first row, status and decision
|
||||
buttons on a second full-width row. From md: the three-column grid. */}
|
||||
<header className="flex flex-wrap items-center gap-2 md:grid md:grid-cols-[1fr_auto_1fr] px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
|
||||
{/* Left: back + shot path */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 md:flex-none">
|
||||
<Link
|
||||
href={`/client/${token}`}
|
||||
className="text-zinc-400 hover:text-white transition-colors flex items-center gap-1.5 text-sm shrink-0"
|
||||
@@ -387,7 +409,7 @@ export default function ClientReviewPage({
|
||||
<span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span>
|
||||
<span className="text-zinc-600">/</span>
|
||||
{contextCode && (
|
||||
<span className="font-mono font-semibold">{contextCode}</span>
|
||||
<span className="font-mono font-semibold truncate">{contextCode}</span>
|
||||
)}
|
||||
{taskName && (
|
||||
<>
|
||||
@@ -417,11 +439,11 @@ export default function ClientReviewPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: status badge + decision buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Center: status badge + decision buttons (their own row on phones) */}
|
||||
<div className="order-last md:order-none flex w-full md:w-auto items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2.5 py-1 rounded-full border hidden sm:inline-flex items-center gap-1 shrink-0",
|
||||
"text-xs px-2.5 py-1 rounded-full border inline-flex items-center gap-1 shrink-0 mr-auto md:mr-0",
|
||||
approvalStyle
|
||||
)}
|
||||
>
|
||||
@@ -509,6 +531,11 @@ export default function ClientReviewPage({
|
||||
comments={comments as any}
|
||||
versionId={version.id}
|
||||
onAddComment={handlePlayerAddComment}
|
||||
handles={version}
|
||||
hqVideoUrl={version.hqFileUrl}
|
||||
// The director watches the cut as cut; handles are still one
|
||||
// toggle away, and flagged whenever the playhead is in one.
|
||||
defaultEditRangeOnly
|
||||
saveAnnotationFn={async (data) => {
|
||||
const res = await fetch(`/api/client/${token}/annotation`, {
|
||||
method: "POST",
|
||||
@@ -542,10 +569,17 @@ export default function ClientReviewPage({
|
||||
className="flex items-center justify-center h-10 w-full border-b border-zinc-800 hover:bg-zinc-800/60 transition-colors shrink-0"
|
||||
title={commentsOpen ? "Collapse notes" : "Expand notes"}
|
||||
>
|
||||
{/* Below the video on phones, so down/up there and right/left from md. */}
|
||||
{commentsOpen ? (
|
||||
<ChevronRight className="h-4 w-4 text-zinc-400" />
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 text-zinc-400 md:hidden" />
|
||||
<ChevronRight className="h-4 w-4 text-zinc-400 hidden md:block" />
|
||||
</>
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4 text-zinc-400" />
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 text-zinc-400 md:hidden" />
|
||||
<ChevronLeft className="h-4 w-4 text-zinc-400 hidden md:block" />
|
||||
</>
|
||||
)}
|
||||
{!commentsOpen && (
|
||||
<MessageSquare className="h-4 w-4 text-zinc-400 mt-1" />
|
||||
@@ -628,11 +662,14 @@ export default function ClientReviewPage({
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-zinc-300 font-medium">
|
||||
{comment.author.name ?? comment.author.email.split("@")[0]}
|
||||
{comment.source === "WHATSAPP"
|
||||
? "Client feedback"
|
||||
: comment.author.name ?? comment.author.email.split("@")[0]}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-xs text-amber-400/80">
|
||||
{frameToTimecode(comment.frameNumber, version.fps)}
|
||||
</span>
|
||||
<HandleBadge frame={comment.frameNumber} />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
|
||||
{comment.canEdit && (
|
||||
@@ -681,8 +718,9 @@ export default function ClientReviewPage({
|
||||
|
||||
{commentsOpen && commentFrame !== null ? (
|
||||
<div className="border-t border-zinc-800 p-3 space-y-2">
|
||||
<p className="text-xs text-zinc-500 font-mono">
|
||||
<p className="flex items-center gap-2 text-xs text-zinc-500 font-mono">
|
||||
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
|
||||
<HandleBadge frame={commentFrame} />
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="Add your note..."
|
||||
|
||||
@@ -28,17 +28,20 @@ import { submitApproval } from "@/actions/approvals";
|
||||
import { internallyApproveShot } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
|
||||
import { VersionHandlesButton } from "@/components/versions/VersionHandlesButton";
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ArrowLeft,
|
||||
Film,
|
||||
ShieldCheck,
|
||||
MessageSquare,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ReviewPageClientProps {
|
||||
@@ -49,12 +52,21 @@ interface ReviewPageClientProps {
|
||||
fps: number;
|
||||
duration: number | null;
|
||||
frameCount: number | null;
|
||||
handleHead: number | null;
|
||||
handleTail: number | null;
|
||||
pictureStartFrame: number | null;
|
||||
hqFileUrl: string | null;
|
||||
highResKey: string | null;
|
||||
highResFilename: string | null;
|
||||
highResSize: bigint | number | null;
|
||||
approvalStatus: string;
|
||||
notes: string | null;
|
||||
isClientVisible: boolean;
|
||||
shot?: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
shotApprovalStatus: string;
|
||||
sharedWithClient: boolean;
|
||||
project: { id: string; name: string; code: string };
|
||||
versions: {
|
||||
id: string;
|
||||
@@ -72,7 +84,7 @@ interface ReviewPageClientProps {
|
||||
id: string;
|
||||
title: string;
|
||||
project: { id: string; name: string; code: string };
|
||||
shot?: { id: string; shotCode: string } | null;
|
||||
shot?: { id: string; shotCode: string; shotApprovalStatus: string; sharedWithClient: boolean } | null;
|
||||
asset?: { id: string; assetCode: string; name: string } | null;
|
||||
versions: {
|
||||
id: string;
|
||||
@@ -96,6 +108,11 @@ interface ReviewPageClientProps {
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1e6))} MB`;
|
||||
}
|
||||
|
||||
const APPROVAL_STATUS_STYLES: Record<string, string> = {
|
||||
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
@@ -124,11 +141,31 @@ export function ReviewPageClient({
|
||||
const [isInternallyApproving, setIsInternallyApproving] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [commentsOpen, setCommentsOpen] = useState(true);
|
||||
const [downloadingProRes, setDownloadingProRes] = useState(false);
|
||||
|
||||
const playerRef = useRef<ReviewPlayerRef>(null);
|
||||
|
||||
// The shot this version belongs to, directly or through its task. Approval
|
||||
// and client sharing are shot-level: the client portal only lists shots
|
||||
// marked sharedWithClient, so sharing has to go through the shot.
|
||||
const reviewShot = version.shot ?? version.task?.shot ?? null;
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const handleDownloadProRes = async () => {
|
||||
setDownloadingProRes(true);
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${version.id}/highres/download`);
|
||||
if (!res.ok) throw new Error("Could not get download link");
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
} catch {
|
||||
toast({ title: "Download failed", variant: "destructive" });
|
||||
} finally {
|
||||
setDownloadingProRes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddComment = useCallback(() => {
|
||||
// Pause player first
|
||||
playerRef.current?.pause();
|
||||
@@ -193,7 +230,7 @@ export function ReviewPageClient({
|
||||
};
|
||||
|
||||
const handleInternalApprove = async () => {
|
||||
const shotId = version.shot?.id;
|
||||
const shotId = reviewShot?.id;
|
||||
if (!shotId) return;
|
||||
setIsInternallyApproving(true);
|
||||
try {
|
||||
@@ -231,8 +268,9 @@ export function ReviewPageClient({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card shrink-0">
|
||||
{/* Top bar — phones: shot + version on the first row, actions wrap to a
|
||||
second row. From md: one row. */}
|
||||
<div className="flex flex-wrap md:flex-nowrap items-center gap-x-3 gap-y-2 px-4 py-2 border-b border-border bg-card shrink-0">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -240,14 +278,14 @@ export function ReviewPageClient({
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||
{project?.code}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{contextCode && (
|
||||
<>
|
||||
<span className="font-mono text-sm font-semibold">{contextCode}</span>
|
||||
<span className="font-mono text-sm font-semibold truncate">{contextCode}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
</>
|
||||
)}
|
||||
@@ -274,6 +312,38 @@ export function ReviewPageClient({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* ProRes — this version's delivery MOV, uploaded by the render worker */}
|
||||
{version.highResKey && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 shrink-0 gap-1 text-xs text-sky-400"
|
||||
disabled={downloadingProRes}
|
||||
onClick={handleDownloadProRes}
|
||||
title={
|
||||
(version.highResFilename ?? "ProRes") +
|
||||
(version.highResSize ? ` · ${formatBytes(Number(version.highResSize))}` : "")
|
||||
}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
<span className="hidden lg:inline">{downloadingProRes ? "Getting link…" : "ProRes"}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Handles — outside the breadcrumb so it never gets squeezed */}
|
||||
{userRole !== "CLIENT" && (
|
||||
<div className="shrink-0">
|
||||
<VersionHandlesButton
|
||||
key={version.id}
|
||||
versionId={version.id}
|
||||
handleHead={version.handleHead}
|
||||
handleTail={version.handleTail}
|
||||
pictureStartFrame={version.pictureStartFrame}
|
||||
onSaved={() => router.refresh()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approval status */}
|
||||
<span
|
||||
className={cn(
|
||||
@@ -284,12 +354,17 @@ export function ReviewPageClient({
|
||||
{version.approvalStatus.replace("_", " ")}
|
||||
</span>
|
||||
|
||||
{/* Actions: their own full-width row on phones; md:contents puts them
|
||||
back in the top bar's single row, exactly as before. */}
|
||||
{(canShare || canApprove) && (
|
||||
<div className="flex w-full items-center justify-end gap-3 md:contents">
|
||||
{/* Share with Client */}
|
||||
{canShare && (
|
||||
<ShareWithClientButton
|
||||
versionId={version.id}
|
||||
isAlreadyShared={version.isClientVisible}
|
||||
onShared={() => {/* router.refresh() handled inside component */}}
|
||||
shot={reviewShot}
|
||||
onShared={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -314,7 +389,8 @@ export function ReviewPageClient({
|
||||
<XCircle className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Reject</span>
|
||||
</Button>
|
||||
{version.shot?.id && (
|
||||
{/* Same rule as the shot page: only while the shot awaits internal approval. */}
|
||||
{reviewShot?.shotApprovalStatus === "PENDING" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1 bg-sky-600 hover:bg-sky-500 text-white"
|
||||
@@ -329,10 +405,12 @@ export function ReviewPageClient({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content — player + comments */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main content — player + comments: stacked on phones, side by side from md */}
|
||||
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
|
||||
{/* Player — 70% */}
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
|
||||
<ReviewPlayer
|
||||
@@ -344,26 +422,35 @@ export function ReviewPageClient({
|
||||
versionId={version.id}
|
||||
onAddComment={handleAddComment}
|
||||
onAnnotationSaved={handleAnnotationSaved}
|
||||
handles={version}
|
||||
hqVideoUrl={version.hqFileUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comment panel — collapsible */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col border-l border-border transition-all duration-200",
|
||||
commentsOpen ? "w-80 xl:w-96" : "w-10"
|
||||
"shrink-0 flex flex-col border-t md:border-t-0 md:border-l border-border transition-all duration-200",
|
||||
commentsOpen ? "h-[45%] md:h-auto md:w-80 xl:w-96" : "h-10 md:h-auto md:w-10"
|
||||
)}
|
||||
>
|
||||
{/* Collapse toggle */}
|
||||
{/* Collapse toggle — the panel sits below the video on phones, so
|
||||
the arrow points down/up there and right/left from md. */}
|
||||
<button
|
||||
onClick={() => setCommentsOpen((o) => !o)}
|
||||
className="flex items-center justify-center h-10 w-full border-b border-border hover:bg-muted/50 transition-colors shrink-0"
|
||||
title={commentsOpen ? "Collapse comments" : "Expand comments"}
|
||||
>
|
||||
{commentsOpen ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground md:hidden" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground hidden md:block" />
|
||||
</>
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground md:hidden" />
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground hidden md:block" />
|
||||
</>
|
||||
)}
|
||||
{!commentsOpen && (
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground mt-1" />
|
||||
|
||||
@@ -45,7 +45,7 @@ async function getReviewData(versionId: string) {
|
||||
task: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
shot: { select: { id: true, shotCode: true } },
|
||||
shot: { select: { id: true, shotCode: true, shotApprovalStatus: true, sharedWithClient: true } },
|
||||
asset: { select: { id: true, assetCode: true, name: true } },
|
||||
versions: {
|
||||
orderBy: { versionNumber: "desc" },
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
useState,
|
||||
useCallback,
|
||||
MouseEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { saveAnnotation } from "@/actions/annotations";
|
||||
import { addComment } from "@/actions/comments";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { letterboxRect } from "@/lib/aspect-overlays";
|
||||
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -30,6 +32,8 @@ interface AddCommentData {
|
||||
|
||||
interface AnnotationCanvasProps {
|
||||
versionId: string;
|
||||
/** The video under the canvas — the canvas is sized to its displayed picture. */
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
frameNumber: number;
|
||||
fps: number;
|
||||
isAnnotating: boolean;
|
||||
@@ -42,6 +46,20 @@ interface AnnotationCanvasProps {
|
||||
addCommentFn?: (data: AddCommentData) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape as held in memory. Legacy (v1.0) shapes keep their points relative to
|
||||
* the player box they were drawn in; `legacyBox` is that box, used to map them
|
||||
* onto the picture once the video's intrinsic size is known.
|
||||
*/
|
||||
type LoadedShape = AnnotationShape & { legacyBox?: { w: number; h: number } };
|
||||
|
||||
function shapesOf(drawingData: AnnotationDrawingData | undefined): LoadedShape[] {
|
||||
const shapes = drawingData?.shapes ?? [];
|
||||
if (!drawingData || drawingData.version === "2.0") return shapes;
|
||||
const legacyBox = { w: drawingData.canvasWidth, h: drawingData.canvasHeight };
|
||||
return shapes.map((s) => ({ ...s, legacyBox }));
|
||||
}
|
||||
|
||||
type DrawingState = {
|
||||
isDrawing: boolean;
|
||||
currentShape: AnnotationShape | null;
|
||||
@@ -50,6 +68,7 @@ type DrawingState = {
|
||||
|
||||
export function AnnotationCanvas({
|
||||
versionId,
|
||||
videoRef,
|
||||
frameNumber,
|
||||
fps,
|
||||
isAnnotating,
|
||||
@@ -73,10 +92,10 @@ export function AnnotationCanvas({
|
||||
|
||||
// Per-frame saved shapes — persists across frame navigation so annotations
|
||||
// remain visible without depending on a parent API refetch.
|
||||
const [savedShapesByFrame, setSavedShapesByFrame] = useState<Record<number, AnnotationShape[]>>(() => {
|
||||
const map: Record<number, AnnotationShape[]> = {};
|
||||
const [savedShapesByFrame, setSavedShapesByFrame] = useState<Record<number, LoadedShape[]>>(() => {
|
||||
const map: Record<number, LoadedShape[]> = {};
|
||||
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
|
||||
const shapes = a.drawingData?.shapes ?? [];
|
||||
const shapes = shapesOf(a.drawingData);
|
||||
map[a.frameNumber] = [...(map[a.frameNumber] ?? []), ...shapes];
|
||||
}
|
||||
return map;
|
||||
@@ -114,10 +133,10 @@ export function AnnotationCanvas({
|
||||
|
||||
setSavedShapesByFrame((prev) => {
|
||||
// Build map from server data
|
||||
const serverMap: Record<number, AnnotationShape[]> = {};
|
||||
const serverMap: Record<number, LoadedShape[]> = {};
|
||||
const serverShapeIds = new Set<string>();
|
||||
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
|
||||
const shapes = a.drawingData?.shapes ?? [];
|
||||
const shapes = shapesOf(a.drawingData);
|
||||
serverMap[a.frameNumber] = [...(serverMap[a.frameNumber] ?? []), ...shapes];
|
||||
for (const s of shapes) serverShapeIds.add(s.id);
|
||||
}
|
||||
@@ -145,6 +164,65 @@ export function AnnotationCanvas({
|
||||
}, [existingAnnotations]);
|
||||
|
||||
|
||||
// ── Picture rect ─────────────────────────────────────────────────────────
|
||||
// The canvas is laid exactly over the displayed picture (not the letterboxed
|
||||
// player box), so normalized points are fractions of the image itself and
|
||||
// stay locked to it at any player size. Same measurement as AspectOverlay;
|
||||
// clientWidth/Height ignore the zoom transform, which the canvas shares.
|
||||
const [pictureRect, setPictureRect] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const measure = () => {
|
||||
const rect = letterboxRect(
|
||||
video.clientWidth,
|
||||
video.clientHeight,
|
||||
video.videoWidth,
|
||||
video.videoHeight
|
||||
);
|
||||
setPictureRect((prev) =>
|
||||
prev &&
|
||||
rect &&
|
||||
prev.left === rect.left &&
|
||||
prev.top === rect.top &&
|
||||
prev.width === rect.width &&
|
||||
prev.height === rect.height
|
||||
? prev
|
||||
: rect
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(video);
|
||||
video.addEventListener("loadedmetadata", measure);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
video.removeEventListener("loadedmetadata", measure);
|
||||
};
|
||||
}, [videoRef]);
|
||||
|
||||
/** A shape's points as fractions of the picture (maps legacy box-relative ones). */
|
||||
const pictureSpacePoints = useCallback(
|
||||
(shape: LoadedShape): AnnotationPoint[] => {
|
||||
const box = shape.legacyBox;
|
||||
const video = videoRef.current;
|
||||
if (!box || !video) return shape.points;
|
||||
const r = letterboxRect(box.w, box.h, video.videoWidth, video.videoHeight);
|
||||
if (!r) return shape.points; // no metadata yet — best effort
|
||||
return shape.points.map((p) => ({
|
||||
x: (p.x * box.w - r.left) / r.width,
|
||||
y: (p.y * box.h - r.top) / r.height,
|
||||
}));
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
// ── Coordinate normalization ─────────────────────────────────────────────
|
||||
const toNormalized = (e: MouseEvent): AnnotationPoint => {
|
||||
const canvas = canvasRef.current!;
|
||||
@@ -164,7 +242,7 @@ export function AnnotationCanvas({
|
||||
const renderShape = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
shape: AnnotationShape,
|
||||
shape: LoadedShape,
|
||||
w: number,
|
||||
h: number
|
||||
) => {
|
||||
@@ -176,7 +254,7 @@ export function AnnotationCanvas({
|
||||
ctx.lineJoin = "round";
|
||||
ctx.globalAlpha = 0.85;
|
||||
|
||||
const pts = shape.points.map((p) => toPixel(p, w, h));
|
||||
const pts = pictureSpacePoints(shape).map((p) => toPixel(p, w, h));
|
||||
|
||||
switch (shape.tool) {
|
||||
case "freehand": {
|
||||
@@ -244,7 +322,7 @@ export function AnnotationCanvas({
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
},
|
||||
[]
|
||||
[pictureSpacePoints]
|
||||
);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
@@ -352,7 +430,7 @@ export function AnnotationCanvas({
|
||||
shapes: [finishedShape],
|
||||
canvasWidth: canvas?.offsetWidth ?? 1920,
|
||||
canvasHeight: canvas?.offsetHeight ?? 1080,
|
||||
version: "1.0",
|
||||
version: "2.0",
|
||||
};
|
||||
|
||||
const doSaveAnnotation = saveAnnotationFn
|
||||
@@ -447,6 +525,12 @@ export function AnnotationCanvas({
|
||||
ref={canvasRef}
|
||||
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
|
||||
style={{
|
||||
...(pictureRect && {
|
||||
left: pictureRect.left,
|
||||
top: pictureRect.top,
|
||||
width: pictureRect.width,
|
||||
height: pictureRect.height,
|
||||
}),
|
||||
cursor: isAnnotating ? "crosshair" : "default",
|
||||
touchAction: isAnnotating ? "none" : "auto",
|
||||
}}
|
||||
|
||||
@@ -21,11 +21,14 @@ import {
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
ImageIcon,
|
||||
Film,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { PLATE_EXTS, plateContentType } from "@/lib/batch-upload-parser";
|
||||
import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route";
|
||||
import type { ThumbnailPreviewItem } from "@/app/api/batch-upload/thumbnails/route";
|
||||
import type { PlatePreviewItem } from "@/app/api/batch-upload/plates/route";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -40,6 +43,12 @@ interface UploadState {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Footage plates are large, so they also carry per-file upload progress. */
|
||||
interface PlateUploadState extends UploadState {
|
||||
/** 0–1, only meaningful while status is "uploading" */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface BatchUploadClientProps {
|
||||
projects: Project[];
|
||||
}
|
||||
@@ -76,6 +85,25 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
setThumbUploadComplete(false);
|
||||
};
|
||||
|
||||
// ── Footage plate upload state ─────────────────────────────────────────────
|
||||
const plateInputRef = useRef<HTMLInputElement>(null);
|
||||
const [plateFiles, setPlateFiles] = useState<File[]>([]);
|
||||
const [plateIsDragging, setPlateIsDragging] = useState(false);
|
||||
const [platePreview, setPlatePreview] = useState<PlatePreviewItem[] | null>(null);
|
||||
const [plateLoadingPreview, setPlateLoadingPreview] = useState(false);
|
||||
const [plateUploadStates, setPlateUploadStates] = useState<Record<string, PlateUploadState>>({});
|
||||
const [plateIsUploading, setPlateIsUploading] = useState(false);
|
||||
const [plateUploadComplete, setPlateUploadComplete] = useState(false);
|
||||
|
||||
const acceptPlate = (f: File) => PLATE_EXTS.has(f.name.split(".").pop()?.toLowerCase() ?? "");
|
||||
|
||||
const resetPlates = () => {
|
||||
setPlateFiles([]);
|
||||
setPlatePreview(null);
|
||||
setPlateUploadStates({});
|
||||
setPlateUploadComplete(false);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFiles([]);
|
||||
setPreview(null);
|
||||
@@ -324,6 +352,136 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
setThumbUploadComplete(true);
|
||||
};
|
||||
|
||||
// ── Footage plate handlers ─────────────────────────────────────────────────
|
||||
|
||||
const handlePlateDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setPlateIsDragging(false);
|
||||
const dropped = Array.from(e.dataTransfer.files).filter(acceptPlate);
|
||||
if (dropped.length > 0) {
|
||||
setPlateFiles(dropped);
|
||||
setPlatePreview(null);
|
||||
setPlateUploadStates({});
|
||||
setPlateUploadComplete(false);
|
||||
} else {
|
||||
toast({
|
||||
title: "No supported files",
|
||||
description: "Only .mov, .mp4, .mxf, .avi, .mkv and .webm files are accepted.",
|
||||
});
|
||||
}
|
||||
}, [toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handlePlateInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files ?? []).filter(acceptPlate);
|
||||
if (selected.length > 0) {
|
||||
setPlateFiles(selected);
|
||||
setPlatePreview(null);
|
||||
setPlateUploadStates({});
|
||||
setPlateUploadComplete(false);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const fetchPlatePreview = async () => {
|
||||
if (!projectId || plateFiles.length === 0) return;
|
||||
setPlateLoadingPreview(true);
|
||||
try {
|
||||
const res = await fetch("/api/batch-upload/plates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId, fileNames: plateFiles.map((f) => f.name) }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Preview failed");
|
||||
const data = await res.json();
|
||||
setPlatePreview(data.items);
|
||||
const states: Record<string, PlateUploadState> = {};
|
||||
for (const item of data.items as PlatePreviewItem[]) states[item.fileName] = { status: "pending" };
|
||||
setPlateUploadStates(states);
|
||||
} catch {
|
||||
toast({ title: "Preview failed", variant: "destructive" });
|
||||
} finally {
|
||||
setPlateLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPlateUpload = async () => {
|
||||
if (!platePreview || !projectId) return;
|
||||
setPlateIsUploading(true);
|
||||
|
||||
const matched = platePreview.filter((i) => i.status === "match");
|
||||
|
||||
for (const item of matched) {
|
||||
const file = plateFiles.find((f) => f.name === item.fileName);
|
||||
if (!file) continue;
|
||||
|
||||
setPlateUploadStates((prev) => ({
|
||||
...prev,
|
||||
[item.fileName]: { status: "uploading", progress: 0 },
|
||||
}));
|
||||
|
||||
try {
|
||||
// Step 1 — presigned PUT URL. Plates are multi-GB, so the bytes go
|
||||
// straight to Hetzner and never touch this server or the proxy.
|
||||
const presignRes = await fetch("/api/upload/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
contentType: plateContentType(file.name),
|
||||
folder: "videos",
|
||||
}),
|
||||
});
|
||||
if (!presignRes.ok) {
|
||||
const d = await presignRes.json().catch(() => ({ error: "Failed to get upload URL" }));
|
||||
throw new Error((d as { error?: string }).error ?? "Failed to get upload URL");
|
||||
}
|
||||
const { presignedUrl, key } = (await presignRes.json()) as {
|
||||
presignedUrl: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
// Step 2 — PUT directly to storage, tracking progress via XHR.
|
||||
await putWithProgress(presignedUrl, file, (fraction) => {
|
||||
setPlateUploadStates((prev) => ({
|
||||
...prev,
|
||||
[item.fileName]: { status: "uploading", progress: fraction },
|
||||
}));
|
||||
});
|
||||
|
||||
// Step 3 — commit the plate record against the matched shot.
|
||||
const commitRes = await fetch("/api/batch-upload/plates/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
shotId: item.shotId,
|
||||
key,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
label: item.label,
|
||||
}),
|
||||
});
|
||||
if (!commitRes.ok) {
|
||||
const d = await commitRes.json().catch(() => ({ error: "Failed to save plate" }));
|
||||
throw new Error((d as { error?: string }).error ?? "Failed to save plate");
|
||||
}
|
||||
|
||||
setPlateUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" } }));
|
||||
} catch (err) {
|
||||
setPlateUploadStates((prev) => ({
|
||||
...prev,
|
||||
[item.fileName]: {
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Upload failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setPlateIsUploading(false);
|
||||
setPlateUploadComplete(true);
|
||||
};
|
||||
|
||||
// ── Derived counts ─────────────────────────────────────────────────────────
|
||||
|
||||
const uploadable = preview?.filter(
|
||||
@@ -359,7 +517,11 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
value={projectId}
|
||||
onValueChange={(v) => {
|
||||
setProjectId(v);
|
||||
// Every preview is resolved against a specific project, so none
|
||||
// of them survive a project switch.
|
||||
reset();
|
||||
resetThumbs();
|
||||
resetPlates();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-80 bg-zinc-800 border-zinc-700">
|
||||
@@ -735,12 +897,260 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Footage plate bulk upload ───────────────────────────────── */}
|
||||
<div className="border-t border-zinc-800 pt-2">
|
||||
<h2 className="text-base font-semibold text-white">Bulk Footage Plate Upload</h2>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Drop plate movies here to add them to a shot's Footage tab. The shot is taken
|
||||
from the start of the filename and the remainder becomes the plate label —{" "}
|
||||
<span className="text-zinc-300 font-mono text-xs">DH2_VFX_058_120_REF.mov</span>{" "}
|
||||
becomes the plate <span className="text-zinc-300">REF</span> on shot{" "}
|
||||
<span className="text-zinc-300 font-mono text-xs">DH2_VFX_058_120</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plate drop zone */}
|
||||
{!plateUploadComplete && (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setPlateIsDragging(true); }}
|
||||
onDragLeave={() => setPlateIsDragging(false)}
|
||||
onDrop={handlePlateDrop}
|
||||
onClick={() => plateInputRef.current?.click()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors select-none",
|
||||
plateIsDragging
|
||||
? "border-emerald-400 bg-emerald-400/5"
|
||||
: "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30"
|
||||
)}
|
||||
>
|
||||
<Film className="h-9 w-9 text-zinc-500 mx-auto mb-3" />
|
||||
<p className="text-zinc-300 font-medium">Drop footage plates here</p>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
MOV, MP4, MXF, AVI, MKV, WebM · or click to browse
|
||||
</p>
|
||||
{plateFiles.length > 0 && (
|
||||
<p className="text-emerald-400 text-sm mt-3 font-medium">
|
||||
{plateFiles.length} file{plateFiles.length !== 1 ? "s" : ""} selected
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={plateInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".mov,.mp4,.m4v,.mxf,.avi,.mkv,.webm,video/*"
|
||||
className="hidden"
|
||||
onChange={handlePlateInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{plateFiles.length > 0 && !platePreview && (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<Button onClick={fetchPlatePreview} disabled={plateLoadingPreview}>
|
||||
{plateLoadingPreview ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Loading preview…</>
|
||||
) : (
|
||||
<>Preview<ChevronRight className="h-4 w-4 ml-2" /></>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={resetPlates} disabled={plateLoadingPreview}>Clear</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Plate preview table */}
|
||||
{platePreview && !plateUploadComplete && (() => {
|
||||
const matched = platePreview.filter((i) => i.status === "match");
|
||||
const skippedPlates = platePreview.filter((i) => i.status !== "match");
|
||||
const dupes = platePreview.filter((i) => i.status === "duplicate");
|
||||
const plateSuccess = Object.values(plateUploadStates).filter((s) => s.status === "success").length;
|
||||
const plateErrors = Object.values(plateUploadStates).filter((s) => s.status === "error").length;
|
||||
const plateDone = plateSuccess + plateErrors;
|
||||
const plateProgress = matched.length > 0 ? (plateDone / matched.length) * 100 : 0;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-base font-medium text-zinc-200">
|
||||
Footage plate preview —{" "}
|
||||
<span className="text-zinc-400 font-normal">
|
||||
{matched.length} matched · {skippedPlates.length} skipped
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 mt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">File</th>
|
||||
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Matched Shot</th>
|
||||
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Plate label</th>
|
||||
<th className="text-left text-xs text-zinc-500 font-normal px-4 py-2.5 w-32">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{platePreview.map((item) => {
|
||||
const state = plateUploadStates[item.fileName];
|
||||
return (
|
||||
<tr key={item.fileName} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileVideo className="h-4 w-4 text-zinc-500 shrink-0" />
|
||||
<span className="text-zinc-200 font-mono text-xs truncate max-w-[240px]">{item.fileName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{item.shotCode ? (
|
||||
<span className="font-mono text-xs text-zinc-300">
|
||||
{item.shotCode}
|
||||
{item.existingPlateCount > 0 && (
|
||||
<span className="text-zinc-600 ml-1.5 font-sans">
|
||||
({item.existingPlateCount} plate{item.existingPlateCount !== 1 ? "s" : ""})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 italic">
|
||||
{item.status === "unsupported" ? "unsupported file" : "no match"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{item.status === "match" ? (
|
||||
<span className="text-zinc-300 text-xs">{item.label}</span>
|
||||
) : (
|
||||
<span className="text-zinc-700 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.status === "duplicate" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-zinc-500" title="This shot already has a plate with this filename">
|
||||
<XCircle className="h-3.5 w-3.5" />already added
|
||||
</span>
|
||||
) : item.status !== "match" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-zinc-500"><XCircle className="h-3.5 w-3.5" />skip</span>
|
||||
) : !state || state.status === "pending" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-400"><ArrowRight className="h-3.5 w-3.5" />add plate</span>
|
||||
) : state.status === "uploading" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{Math.round((state.progress ?? 0) * 100)}%
|
||||
</span>
|
||||
) : state.status === "success" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle2 className="h-3.5 w-3.5" />done</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-400" title={state.error}><XCircle className="h-3.5 w-3.5" />error</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{plateIsUploading && (
|
||||
<div className="px-6 py-3 border-t border-zinc-800">
|
||||
<div className="flex justify-between text-xs text-zinc-400 mb-1.5">
|
||||
<span>Uploading {Math.min(plateDone + 1, matched.length)} of {matched.length}…</span>
|
||||
<span>{Math.round(plateProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={plateProgress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex items-center justify-between gap-4">
|
||||
<p className="text-zinc-500 text-xs">
|
||||
{matched.length} plate{matched.length !== 1 ? "s" : ""} will be added
|
||||
{skippedPlates.length > 0 && (
|
||||
<span className="text-zinc-600">
|
||||
{" "}· {skippedPlates.length} skipped
|
||||
{dupes.length > 0 && ` (${dupes.length} already uploaded)`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-3 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={resetPlates} disabled={plateIsUploading}>Change files</Button>
|
||||
<Button size="sm" onClick={startPlateUpload} disabled={plateIsUploading || matched.length === 0}>
|
||||
{plateIsUploading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Uploading…</>
|
||||
) : (
|
||||
`Upload ${matched.length} plate${matched.length !== 1 ? "s" : ""}`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Plate done summary */}
|
||||
{plateUploadComplete && (() => {
|
||||
const plateSuccess = Object.values(plateUploadStates).filter((s) => s.status === "success").length;
|
||||
const plateErrors = Object.values(plateUploadStates).filter((s) => s.status === "error").length;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-8 pb-8 flex flex-col items-center gap-4 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||
<div>
|
||||
<p className="text-white font-semibold text-lg">Footage plates uploaded</p>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
{plateSuccess} added{plateErrors > 0 && <span className="text-red-400"> · {plateErrors} failed</span>}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={resetPlates} className="mt-2">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />Upload more plates
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PUT a file to a presigned storage URL, reporting upload progress.
|
||||
*
|
||||
* `fetch` gives no upload progress, and footage plates are big enough that a
|
||||
* silent multi-minute wait per file is unusable — hence XHR.
|
||||
*/
|
||||
function putWithProgress(
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress: (fraction: number) => void
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("PUT", url);
|
||||
xhr.setRequestHeader("Content-Type", plateContentType(file.name));
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else if (xhr.status === 403)
|
||||
reject(new Error("Storage upload failed — check Hetzner CORS / bucket policy"));
|
||||
else reject(new Error(`Storage upload failed (HTTP ${xhr.status})`));
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => reject(new Error("Network error")));
|
||||
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sub-components ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PreviewRow({
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ExternalLink, Copy, Check, Trash2, Clock, Film } from "lucide-react";
|
||||
import { ExternalLink, Copy, Check, Trash2, Clock, Film, RotateCcw, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn, formatRelativeDate } from "@/lib/utils";
|
||||
|
||||
@@ -28,9 +35,36 @@ const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
||||
export function ReviewSessionList({ sessions }: ReviewSessionListProps) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [reactivatingId, setReactivatingId] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
// Same token, new expiry — the URL the client already has starts working again.
|
||||
const handleReactivate = async (id: string, days: number) => {
|
||||
setReactivatingId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/review-sessions?id=${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expiresInDays: days }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error);
|
||||
}
|
||||
toast({ title: "Review link reactivated", description: `Valid for ${days} more days — same URL.` });
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to reactivate link",
|
||||
description: e instanceof Error && e.message ? e.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setReactivatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (token: string, id: string) => {
|
||||
const url = `${APP_URL}/client/${token}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
@@ -122,6 +156,33 @@ export function ReviewSessionList({ sessions }: ReviewSessionListProps) {
|
||||
<span className="text-xs text-zinc-500 mr-2">
|
||||
{session.accessCount} view{session.accessCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{expired && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 gap-1.5 text-emerald-400 hover:bg-emerald-500/10 border-emerald-500/30"
|
||||
disabled={reactivatingId === session.id}
|
||||
>
|
||||
{reactivatingId === session.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Reactivate
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="text-xs text-zinc-500">Valid for</DropdownMenuLabel>
|
||||
{[7, 14, 30, 60, 90].map((d) => (
|
||||
<DropdownMenuItem key={d} onClick={() => handleReactivate(session.id, d)}>
|
||||
{d} days
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getInitials, formatRelativeDate } from "@/lib/utils";
|
||||
import { frameToTimecode } from "@/lib/frame-utils";
|
||||
import { HandleBadge } from "@/components/player/HandleBadge";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
@@ -151,6 +152,7 @@ export function CommentPanel({
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{frameToTimecode(activeCommentFrame, fps)}
|
||||
</span>
|
||||
<HandleBadge frame={activeCommentFrame} />
|
||||
</div>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
@@ -340,14 +342,25 @@ function CommentThread({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium">
|
||||
{comment.author?.name ?? comment.author?.email ?? "Deleted User"}
|
||||
{comment.source === "WHATSAPP"
|
||||
? "Client feedback"
|
||||
: comment.author?.name ?? comment.author?.email ?? "Deleted User"}
|
||||
</span>
|
||||
{comment.source === "WHATSAPP" && (
|
||||
<span
|
||||
className="rounded border border-indigo-500/20 bg-indigo-500/10 px-1 py-px text-[10px] text-indigo-400"
|
||||
title={`Pasted from WhatsApp by ${comment.author?.name ?? comment.author?.email ?? "someone"}`}
|
||||
>
|
||||
via {comment.author?.name?.split(" ")[0] ?? "WhatsApp"}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
|
||||
onClick={() => onJumpToFrame(comment.frameNumber)}
|
||||
>
|
||||
F{String(comment.frameNumber).padStart(4, "0")}
|
||||
</button>
|
||||
<HandleBadge frame={comment.frameNumber} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatRelativeDate(comment.createdAt)}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Link from "next/link";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AwaitingDelivery as AwaitingDeliveryRow } from "@/lib/dashboard/ops";
|
||||
import type { DeliveryRequestView } from "@/lib/render-pipeline/delivery-requests";
|
||||
import { DELIVERABLE_LABEL, type Deliverable } from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
const STAGE_LABEL: Record<AwaitingDeliveryRow["stage"], string> = {
|
||||
AWAITING_UPLOAD: "Not uploaded",
|
||||
AWAITING_SHARE: "Uploaded — link not shared",
|
||||
};
|
||||
|
||||
const SEVERITY_STYLES: Record<AwaitingDeliveryRow["severity"], string> = {
|
||||
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||||
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
critical: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
const ROW_CLASS =
|
||||
"flex items-center gap-3 border-b border-zinc-800/60 px-4 py-2.5 last:border-b-0 hover:bg-zinc-900/40";
|
||||
const PILL_CLASS = "shrink-0 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap";
|
||||
|
||||
/**
|
||||
* Everything a client is owed: what they asked for that has not fully gone
|
||||
* out, and built packages they do not have yet. A row only leaves this list
|
||||
* when the upload location and who the link went to are on record (or a
|
||||
* request is cancelled with a reason) — never because time passed or someone
|
||||
* remembers doing it.
|
||||
*/
|
||||
export function AwaitingDelivery({
|
||||
requests,
|
||||
packages,
|
||||
}: {
|
||||
requests: DeliveryRequestView[];
|
||||
packages: AwaitingDeliveryRow[];
|
||||
}) {
|
||||
if (requests.length === 0 && packages.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-card px-4 py-4 text-sm text-zinc-500">
|
||||
Nothing is owed to a client.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-card overflow-hidden">
|
||||
{requests.map((r) => (
|
||||
<Link key={r.id} href="/pipeline/deliveries" className={ROW_CLASS}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-white">
|
||||
<span className="font-medium">Request #{r.requestNumber}</span>{" "}
|
||||
<span className="text-zinc-300">
|
||||
{DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable}
|
||||
{r.episode ? ` for ${r.episode}` : ""}
|
||||
</span>{" "}
|
||||
<span className="text-zinc-500">{r.projectCode}</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
asked by {r.requestedBy} {formatDistanceToNowStrict(r.createdAt)} ago
|
||||
{r.dueDate ? ` · due ${format(r.dueDate, "d MMM")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(PILL_CLASS, SEVERITY_STYLES[r.severity])}>
|
||||
{r.deliveredCount} of {r.lineCount} delivered
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{packages.map((p) => (
|
||||
<Link key={p.id} href="/pipeline/deliveries" className={ROW_CLASS}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-white">
|
||||
<span className="font-mono font-medium">#{p.packageNumber}</span>{" "}
|
||||
<span className="text-zinc-300">{p.name}</span>{" "}
|
||||
<span className="text-zinc-500">
|
||||
{p.projectCode}
|
||||
{p.episode ? ` · ${p.episode}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500">
|
||||
{p.itemCount} shot{p.itemCount === 1 ? "" : "s"} · built {formatDistanceToNowStrict(p.builtAt)} ago
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(PILL_CLASS, SEVERITY_STYLES[p.severity])}>{STAGE_LABEL[p.stage]}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EXPORT_STATUS_STYLES, statusLabel } from "@/app/(dashboard)/pipeline/status-colors";
|
||||
import type { BlockedExport } from "@/lib/dashboard/ops";
|
||||
|
||||
/**
|
||||
* The exception list: exports that have stopped moving and need a person.
|
||||
*
|
||||
* Read by scanning down the left edge — the severity rail says which rows are
|
||||
* dead in the water (a failed state, which nothing retries) versus merely
|
||||
* overdue (still nominally in flight, or waiting on a QC pass). The reason
|
||||
* column exists so the answer to "what happened" doesn't require opening
|
||||
* anything.
|
||||
*/
|
||||
export function BlockedQueue({ blocked }: { blocked: BlockedExport[] }) {
|
||||
const critical = blocked.filter((b) => b.severity === "critical").length;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{blocked.length === 0
|
||||
? "Nothing blocked"
|
||||
: `${blocked.length} export${blocked.length === 1 ? "" : "s"} stalled`}
|
||||
{critical > 0 && (
|
||||
<span className="ml-2 text-xs font-normal text-red-400">{critical} failed</span>
|
||||
)}
|
||||
</h3>
|
||||
<Link href="/pipeline" className="text-xs text-zinc-400 hover:text-amber-400">
|
||||
Open render queue →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{blocked.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-sm text-zinc-500">
|
||||
Every export is either moving or done.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
|
||||
<th className="w-[3px] p-0" />
|
||||
<th className="px-4 py-2.5 font-medium">Shot</th>
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
<th className="px-4 py-2.5 font-medium whitespace-nowrap">Stuck</th>
|
||||
<th className="px-4 py-2.5 font-medium">Why</th>
|
||||
<th className="px-4 py-2.5 font-medium">Artist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blocked.map((b) => (
|
||||
<tr key={b.exportId} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
|
||||
<td className="w-[3px] p-0">
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-[3px]",
|
||||
b.severity === "critical" ? "bg-red-500" : "bg-amber-500"
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/pipeline/exports/${b.exportId}`}
|
||||
className="font-mono text-[13px] font-medium text-white hover:text-amber-400"
|
||||
>
|
||||
{b.shotCode}{" "}
|
||||
<span className="text-zinc-500">{b.versionString}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{b.projectCode}
|
||||
{b.episode ? ` · ep ${b.episode}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||
EXPORT_STATUS_STYLES[b.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
|
||||
)}
|
||||
>
|
||||
{statusLabel(b.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-4 py-2.5 tabular-nums whitespace-nowrap",
|
||||
b.severity === "critical" ? "text-red-400" : "text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{formatDistanceToNowStrict(b.since)}
|
||||
</td>
|
||||
{/* The reason can be an aerender error several lines long;
|
||||
it is clamped here and given in full on hover. */}
|
||||
<td className="px-4 py-2.5 text-zinc-400 max-w-md" title={b.reason}>
|
||||
<span className="line-clamp-2">{b.reason}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-300 whitespace-nowrap">
|
||||
{b.artistName ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CountdownVerdict, EpisodeCountdown } from "@/lib/dashboard/ops";
|
||||
|
||||
/**
|
||||
* Are we going to make the date? One card per episode, sorted by how soon.
|
||||
*
|
||||
* The bar is the episode's shots by where they actually are, and the row under
|
||||
* it is the only comparison that matters: shots per day still needed against
|
||||
* shots per day the studio has managed this week.
|
||||
*/
|
||||
|
||||
const VERDICT: Record<CountdownVerdict, { rail: string; chip: string; label: string }> = {
|
||||
overdue: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Overdue" },
|
||||
behind: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Behind" },
|
||||
stalled: { rail: "bg-red-500", chip: "text-red-400 bg-red-500/10", label: "Nothing delivered this week" },
|
||||
tight: { rail: "bg-amber-500", chip: "text-amber-400 bg-amber-500/10", label: "No slack" },
|
||||
ahead: { rail: "bg-green-500", chip: "text-green-400 bg-green-500/10", label: "On track" },
|
||||
complete: { rail: "bg-emerald-500", chip: "text-emerald-400 bg-emerald-500/10", label: "All delivered" },
|
||||
};
|
||||
|
||||
/** Two decimals is false precision for a shot count; one is the real resolution. */
|
||||
const perDay = (n: number) => n.toFixed(1);
|
||||
|
||||
export function DeliveryCountdown({ episodes }: { episodes: EpisodeCountdown[] }) {
|
||||
if (episodes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-card px-4 py-8 text-center text-sm text-zinc-500">
|
||||
No delivery dates set. Add them on a project's Episodes tab to see the countdown here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
|
||||
{episodes.map((ep) => {
|
||||
const verdict = VERDICT[ep.verdict];
|
||||
const pct = (n: number) => (ep.total === 0 ? 0 : (n / ep.total) * 100);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`${ep.projectId}:${ep.episode ?? ""}`}
|
||||
href={`/shot-status?projectId=${ep.projectId}`}
|
||||
className="group relative flex flex-col gap-3.5 overflow-hidden rounded-lg border border-zinc-800 bg-card p-4 pl-[18px] transition-colors hover:border-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
|
||||
>
|
||||
{/* Severity rail — the card itself stays neutral so only the
|
||||
episode in trouble pulls the eye. */}
|
||||
<span className={cn("absolute inset-y-0 left-0 w-[3px]", verdict.rail)} aria-hidden="true" />
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[15px] font-semibold text-white">
|
||||
{ep.projectCode}
|
||||
{ep.episode && <span className="font-normal text-zinc-500"> · ep {ep.episode}</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-zinc-400">
|
||||
Due {format(ep.dueDate, "EEE d MMM yyyy")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<b className="block text-3xl font-bold leading-none tabular-nums text-white">
|
||||
{Math.abs(ep.daysLeft)}
|
||||
</b>
|
||||
<small className="text-[11px] uppercase tracking-wide text-zinc-500">
|
||||
{ep.daysLeft < 0 ? "days over" : "days left"}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
className="flex h-2 overflow-hidden rounded-full bg-zinc-800"
|
||||
role="img"
|
||||
aria-label={`${ep.delivered} of ${ep.total} shots delivered, ${ep.inQc} through QC, ${ep.inProgress} in progress`}
|
||||
>
|
||||
<i className="block h-full bg-emerald-500" style={{ width: `${pct(ep.delivered)}%` }} />
|
||||
<i className="block h-full bg-amber-500" style={{ width: `${pct(ep.inQc)}%` }} />
|
||||
<i className="block h-full bg-blue-500" style={{ width: `${pct(ep.inProgress)}%` }} />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-400">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<i className="h-2 w-2 rounded-sm bg-emerald-500" /> {ep.delivered} delivered
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<i className="h-2 w-2 rounded-sm bg-amber-500" /> {ep.inQc} in QC
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<i className="h-2 w-2 rounded-sm bg-blue-500" /> {ep.inProgress} in progress
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<i className="h-2 w-2 rounded-sm bg-zinc-700" /> {ep.notStarted} not started
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-zinc-800/60 pt-3">
|
||||
<div>
|
||||
<div className="text-[11px] text-zinc-500">Needed / day</div>
|
||||
<div className="font-mono text-[15px] tabular-nums text-white">
|
||||
{perDay(ep.requiredPerDay)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-zinc-500">Last 7 days</div>
|
||||
<div className="font-mono text-[15px] tabular-nums text-white">
|
||||
{perDay(ep.actualPerDay)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("rounded-full px-2.5 py-1 text-xs font-semibold", verdict.chip)}>
|
||||
{ep.verdict === "behind" && ep.daysBehind
|
||||
? `${ep.daysBehind} day${ep.daysBehind === 1 ? "" : "s"} behind`
|
||||
: ep.verdict === "overdue" && ep.daysBehind
|
||||
? `${ep.daysBehind} day${ep.daysBehind === 1 ? "" : "s"} over`
|
||||
: verdict.label}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatEta } from "@/app/(dashboard)/pipeline/status-colors";
|
||||
import type { FarmStatus } from "@/lib/dashboard/ops";
|
||||
|
||||
/**
|
||||
* Farm health: who is up, what is waiting, and who is running an old worker.
|
||||
*
|
||||
* The version row is not trivia — a worker's version is what decides whether
|
||||
* it has a given fix, and a machine that quietly stayed behind keeps producing
|
||||
* the bug everyone else has stopped seeing.
|
||||
*/
|
||||
export function FarmStrip({ farm }: { farm: FarmStatus }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{farm.online} of {farm.total} online
|
||||
</h3>
|
||||
<Link href="/pipeline/machines" className="text-xs text-zinc-400 hover:text-amber-400">
|
||||
Machines →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{farm.machines.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
No machines have registered yet.
|
||||
</p>
|
||||
) : (
|
||||
farm.machines.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center gap-3 border-b border-zinc-800/60 px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
m.status === "ONLINE"
|
||||
? "bg-green-500 ring-4 ring-green-500/15"
|
||||
: m.status === "DISABLED"
|
||||
? "bg-zinc-600"
|
||||
: "bg-zinc-500"
|
||||
)}
|
||||
title={m.status}
|
||||
/>
|
||||
<span className="w-24 shrink-0 font-mono text-[12.5px] text-white">{m.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-zinc-400">
|
||||
{m.currentJob
|
||||
? `${m.currentJob.shotCode ?? "job"} ${m.currentJob.versionString ?? ""} · ${Math.round(
|
||||
(m.currentJob.progress ?? 0) * 100
|
||||
)}% · ETA ${formatEta(m.currentJob.etaSeconds)}`
|
||||
: m.status === "ONLINE"
|
||||
? "Idle"
|
||||
: m.lastSeenAt
|
||||
? `Offline · last seen ${formatDistanceToNowStrict(new Date(m.lastSeenAt))} ago`
|
||||
: "Never seen"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 font-mono text-[11.5px]",
|
||||
farm.latestWorkerVersion && m.workerVersion !== farm.latestWorkerVersion
|
||||
? "text-amber-400"
|
||||
: "text-zinc-500"
|
||||
)}
|
||||
>
|
||||
{m.workerVersion ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 border-t border-zinc-800/60">
|
||||
<div className="border-r border-zinc-800/60 px-4 py-2.5">
|
||||
<b className="block font-mono text-lg font-semibold leading-tight text-white">
|
||||
{farm.queued.render}
|
||||
</b>
|
||||
<small className="text-xs text-zinc-500">renders queued</small>
|
||||
</div>
|
||||
<div className="border-r border-zinc-800/60 px-4 py-2.5">
|
||||
<b className="block font-mono text-lg font-semibold leading-tight text-white">
|
||||
{farm.queued.preview}
|
||||
</b>
|
||||
<small className="text-xs text-zinc-500">previews queued</small>
|
||||
</div>
|
||||
<div className="px-4 py-2.5">
|
||||
<b className="block font-mono text-lg font-semibold leading-tight text-white">
|
||||
{farm.oldestQueuedMs == null
|
||||
? "—"
|
||||
: formatDistanceToNowStrict(new Date(Date.now() - farm.oldestQueuedMs))}
|
||||
</b>
|
||||
<small className="text-xs text-zinc-500">oldest wait</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{farm.behindMachines.length > 0 && (
|
||||
<div className="flex gap-2 border-t border-amber-500/20 bg-amber-500/[0.07] px-4 py-2.5 text-xs text-amber-400">
|
||||
<span aria-hidden="true">▲</span>
|
||||
<span>
|
||||
{farm.behindMachines.length} machine
|
||||
{farm.behindMachines.length === 1 ? " is" : "s are"} behind{" "}
|
||||
<span className="font-mono">{farm.latestWorkerVersion}</span> —{" "}
|
||||
{farm.behindMachines
|
||||
.map((m) => `${m.name} (${m.workerVersion ?? "unknown"})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||
|
||||
/** The dashboard's way in to logging what a client just asked for. */
|
||||
export function LogRequestButton() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
Log request
|
||||
</Button>
|
||||
<LogRequestDialog open={open} onClose={() => setOpen(false)} onLogged={() => router.refresh()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { QcQueueEntry } from "@/lib/dashboard/ops";
|
||||
|
||||
/**
|
||||
* What is waiting on someone to watch the frames, oldest first.
|
||||
*
|
||||
* Everything upstream of QC is worked by the farm; this is the one stage that
|
||||
* only drains when a person sits down to it, which is why depth here is worth
|
||||
* a panel of its own rather than a filter on the queue page.
|
||||
*/
|
||||
export function QcQueue({ entries, staleHours }: { entries: QcQueueEntry[]; staleHours: number }) {
|
||||
const oldest = entries[0];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{entries.length === 0
|
||||
? "QC queue empty"
|
||||
: `${entries.length} waiting on QC`}
|
||||
{oldest && (
|
||||
<span className="ml-2 text-xs font-normal text-zinc-500">
|
||||
oldest {formatDistanceToNowStrict(oldest.since)}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<Link
|
||||
href="/pipeline?status=READY_FOR_QC"
|
||||
className="text-xs text-zinc-400 hover:text-amber-400"
|
||||
>
|
||||
Open QC →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-sm text-zinc-500">
|
||||
Nothing is waiting on a QC pass.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
|
||||
<th className="px-4 py-2.5 font-medium">Shot</th>
|
||||
<th className="px-4 py-2.5 font-medium">Version</th>
|
||||
<th className="px-4 py-2.5 font-medium">Waiting</th>
|
||||
<th className="px-4 py-2.5 font-medium">Frames</th>
|
||||
<th className="px-4 py-2.5 font-medium">Submitted by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => {
|
||||
const stale = Date.now() - e.since.getTime() > staleHours * 3600_000;
|
||||
return (
|
||||
<tr key={e.exportId} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/pipeline/exports/${e.exportId}`}
|
||||
className="font-mono text-[13px] font-medium text-white hover:text-amber-400"
|
||||
>
|
||||
{e.shotCode}
|
||||
</Link>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{e.projectCode}
|
||||
{e.episode ? ` · ep ${e.episode}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-[13px] text-zinc-300">
|
||||
{e.versionString}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"px-4 py-2.5 tabular-nums whitespace-nowrap",
|
||||
stale ? "text-amber-400" : "text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{formatDistanceToNowStrict(e.since)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-[13px] tabular-nums text-zinc-400 whitespace-nowrap">
|
||||
{e.frameStart}–{e.frameEnd}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-300 whitespace-nowrap">
|
||||
{e.submittedBy ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DELIVERY_STATUS_STYLES } from "@/app/(dashboard)/pipeline/status-colors";
|
||||
import type { DeliverySummary } from "@/lib/dashboard/ops";
|
||||
|
||||
/**
|
||||
* The last few packages, and whether any of them went wrong.
|
||||
*
|
||||
* A failed build is otherwise silent — the exports stay READY_FOR_DELIVERY and
|
||||
* nothing reaches the delivery volume, but no one finds out until the client
|
||||
* asks where the shots are.
|
||||
*/
|
||||
export function RecentDeliveries({ deliveries }: { deliveries: DeliverySummary[] }) {
|
||||
const problems = deliveries.filter((d) => d.status === "FAILED" || d.stuck).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-card overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-zinc-800/60 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Recent packages
|
||||
{problems > 0 && (
|
||||
<span className="ml-2 text-xs font-normal text-red-400">
|
||||
{problems} need{problems === 1 ? "s" : ""} attention
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<Link href="/pipeline/deliveries" className="text-xs text-zinc-400 hover:text-amber-400">
|
||||
All deliveries →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{deliveries.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-zinc-500">No packages built yet.</p>
|
||||
) : (
|
||||
deliveries.map((d) => (
|
||||
<Link
|
||||
key={d.id}
|
||||
href="/pipeline/deliveries"
|
||||
className="flex items-center gap-3 border-b border-zinc-800/60 px-4 py-2.5 last:border-b-0 hover:bg-zinc-900/40"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-white">
|
||||
<span className="font-mono font-medium">#{d.packageNumber}</span>{" "}
|
||||
<span className="text-zinc-500">
|
||||
{d.projectCode}
|
||||
{d.episode ? ` · ${d.episode}` : ""}
|
||||
{d.kind === "MOV_REVIEW" ? " · review" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-zinc-500" title={d.errorMessage ?? undefined}>
|
||||
{d.status === "FAILED" && d.errorMessage
|
||||
? d.errorMessage
|
||||
: `${d.itemCount} shot${d.itemCount === 1 ? "" : "s"} · ${d.atLabel} ${formatDistanceToNowStrict(d.at)} ago`}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||
// A build that never finished reads as a problem, not as work
|
||||
// still in progress.
|
||||
d.stuck
|
||||
? "bg-red-500/15 text-red-400 border-red-500/30"
|
||||
: DELIVERY_STATUS_STYLES[d.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
|
||||
)}
|
||||
>
|
||||
{d.stuck ? "STUCK" : d.status}
|
||||
</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
ClipboardCheck,
|
||||
PackageCheck,
|
||||
Eye,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { DashboardStats } from "@/types";
|
||||
@@ -15,47 +14,54 @@ interface StatsCardsProps {
|
||||
stats: DashboardStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Five numbers that can go down. Each one links to the page that lists what it
|
||||
* counts — a figure with no way through to the rows behind it is wallpaper.
|
||||
*/
|
||||
export function StatsCards({ stats }: StatsCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
label: "Awaiting Review",
|
||||
value: stats.awaitingReview,
|
||||
icon: Clock,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
ring: "ring-amber-500/20",
|
||||
},
|
||||
{
|
||||
label: "Needs Revisions",
|
||||
value: stats.needsRevisions,
|
||||
icon: AlertCircle,
|
||||
color: "text-orange-400",
|
||||
bg: "bg-orange-500/10",
|
||||
ring: "ring-orange-500/20",
|
||||
},
|
||||
{
|
||||
label: "Approved",
|
||||
value: stats.approved,
|
||||
icon: CheckCircle2,
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
ring: "ring-emerald-500/20",
|
||||
},
|
||||
{
|
||||
label: "Overdue Tasks",
|
||||
value: stats.tasksOverdue ?? stats.overdue,
|
||||
icon: XCircle,
|
||||
label: "Blocked",
|
||||
value: stats.blocked,
|
||||
href: "#blocked",
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
ring: "ring-red-500/20",
|
||||
// The only card that earns emphasis when it is non-zero: everything else
|
||||
// here is a queue depth, this one is work that has stopped.
|
||||
emphasise: stats.blocked > 0,
|
||||
},
|
||||
{
|
||||
label: "Active Projects",
|
||||
value: stats.activeProjects,
|
||||
icon: TrendingUp,
|
||||
label: "In QC",
|
||||
value: stats.inQc,
|
||||
href: "/pipeline?status=READY_FOR_QC",
|
||||
icon: ClipboardCheck,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
},
|
||||
{
|
||||
label: "Ready to deliver",
|
||||
value: stats.readyToDeliver,
|
||||
href: "/pipeline/deliveries",
|
||||
icon: PackageCheck,
|
||||
color: "text-green-400",
|
||||
bg: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
label: "With the client",
|
||||
value: stats.awaitingClientReview,
|
||||
href: "/playlist",
|
||||
icon: Eye,
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
ring: "ring-blue-500/20",
|
||||
},
|
||||
{
|
||||
label: "Active projects",
|
||||
value: stats.activeProjects,
|
||||
href: "/projects",
|
||||
icon: TrendingUp,
|
||||
color: "text-zinc-400",
|
||||
bg: "bg-zinc-500/10",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -64,19 +70,31 @@ export function StatsCards({ stats }: StatsCardsProps) {
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card key={card.label}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className={cn("p-2 rounded-md", card.bg)}>
|
||||
<Icon className={cn("h-4 w-4", card.color)} />
|
||||
<Link key={card.label} href={card.href} className="group rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500">
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full transition-colors group-hover:border-zinc-700",
|
||||
card.emphasise && "border-red-500/40"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className={cn("p-2 rounded-md", card.bg)}>
|
||||
<Icon className={cn("h-4 w-4", card.color)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white">
|
||||
{card.value}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400 mt-0.5">{card.label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p
|
||||
className={cn(
|
||||
"text-3xl font-bold tabular-nums",
|
||||
card.emphasise ? "text-red-400" : "text-white"
|
||||
)}
|
||||
>
|
||||
{card.value}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400 mt-0.5">{card.label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import type { WhoseCourt as WhoseCourtData } from "@/lib/dashboard/ops";
|
||||
|
||||
/**
|
||||
* Which waits are the client's and which are ours.
|
||||
*
|
||||
* Two numbers rather than one, because they call for opposite responses: a
|
||||
* version unanswered for a fortnight is a phone call, while notes nobody has
|
||||
* picked up is a scheduling problem.
|
||||
*/
|
||||
export function WhoseCourt({ court }: { court: WhoseCourtData }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 rounded-lg border border-zinc-800 bg-card overflow-hidden">
|
||||
<Side
|
||||
title="Waiting on the client"
|
||||
subtitle="Sent for review, no verdict yet"
|
||||
side={court.client}
|
||||
empty="Nothing is with the client."
|
||||
className="border-b border-zinc-800/60 sm:border-b-0 sm:border-r"
|
||||
/>
|
||||
<Side
|
||||
title="Waiting on us"
|
||||
subtitle="Notes in, still unresolved"
|
||||
side={court.us}
|
||||
empty="No open notes."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Side({
|
||||
title,
|
||||
subtitle,
|
||||
side,
|
||||
empty,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
side: WhoseCourtData["client"];
|
||||
empty: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`px-4 py-3 ${className ?? ""}`}>
|
||||
<h4 className="text-xs font-semibold text-white">{title}</h4>
|
||||
<p className="text-[11px] text-zinc-500">{subtitle}</p>
|
||||
<b className="mt-2 mb-2 block font-mono text-2xl font-bold leading-none tabular-nums text-white">
|
||||
{side.count}
|
||||
</b>
|
||||
{side.items.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500">{empty}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{side.items.map((item) => (
|
||||
<li key={item.id} className="flex items-baseline justify-between gap-3 text-xs">
|
||||
<Link
|
||||
href={item.href}
|
||||
className="truncate font-mono text-[11.5px] text-zinc-200 hover:text-amber-400"
|
||||
>
|
||||
{item.shotCode}
|
||||
</Link>
|
||||
<span className="shrink-0 tabular-nums text-zinc-500">{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { DELIVERABLES, DELIVERABLE_LABEL, parseShotCodes, type Deliverable } from "@/lib/render-pipeline/delivery-request-state";
|
||||
|
||||
const SELECT_CLASS = "w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-200";
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log what a client asked for. The ask arrives over chat, so this has to be
|
||||
* quicker than deciding to do it later: a project, the shots, who asked.
|
||||
*
|
||||
* With `preset` the shots are already chosen (the shot-status selection);
|
||||
* otherwise they come from an episode or a pasted list of codes.
|
||||
*/
|
||||
export function LogRequestDialog({
|
||||
open,
|
||||
onClose,
|
||||
onLogged,
|
||||
preset,
|
||||
defaultProjectId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onLogged: () => void;
|
||||
preset?: { projectId: string; shotIds: string[] };
|
||||
defaultProjectId?: string;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [projects, setProjects] = useState<ProjectOption[]>([]);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [episodes, setEpisodes] = useState<{ episode: string; shotCount: number }[]>([]);
|
||||
const [mode, setMode] = useState<"episode" | "codes">("episode");
|
||||
const [episode, setEpisode] = useState("");
|
||||
const [codes, setCodes] = useState("");
|
||||
const [deliverable, setDeliverable] = useState<Deliverable>("EXR");
|
||||
const [requestedBy, setRequestedBy] = useState("");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setProjectId(preset?.projectId ?? defaultProjectId ?? "");
|
||||
setMode("episode");
|
||||
setEpisode("");
|
||||
setCodes("");
|
||||
setDeliverable("EXR");
|
||||
setRequestedBy("");
|
||||
setDueDate("");
|
||||
setNote("");
|
||||
setError(null);
|
||||
if (preset) return;
|
||||
fetch("/api/projects")
|
||||
.then((res) => (res.ok ? res.json() : { projects: [] }))
|
||||
.then((body) => setProjects((body.projects ?? body) as ProjectOption[]))
|
||||
.catch(() => setProjects([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setEpisodes([]);
|
||||
setEpisode("");
|
||||
if (!open || preset || !projectId) return;
|
||||
let stale = false;
|
||||
fetch(`/api/delivery-requests/options?projectId=${encodeURIComponent(projectId)}`)
|
||||
.then((res) => (res.ok ? res.json() : { episodes: [] }))
|
||||
.then((body) => {
|
||||
if (!stale) setEpisodes(body.episodes ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, projectId]);
|
||||
|
||||
const codeCount = parseShotCodes(codes).length;
|
||||
const hasShots = preset ? preset.shotIds.length > 0 : mode === "episode" ? Boolean(episode) : codeCount > 0;
|
||||
const canSave = Boolean(projectId) && hasShots && requestedBy.trim().length > 0 && !saving;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const res = await fetch("/api/delivery-requests", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
...(preset ? { shotIds: preset.shotIds } : mode === "episode" ? { episode } : { shotCodes: codes }),
|
||||
deliverable,
|
||||
requestedBy,
|
||||
dueDate: dueDate || null,
|
||||
note: note || null,
|
||||
}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setSaving(false);
|
||||
if (!res.ok) {
|
||||
// Shown in place rather than as a toast: an unmatched code has to be
|
||||
// fixed in the text right above it.
|
||||
setError(body.error ?? res.statusText);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: `Request #${body.request.requestNumber} logged`,
|
||||
description: `${body.request.lineCount} shot${body.request.lineCount === 1 ? "" : "s"} awaiting delivery`,
|
||||
});
|
||||
onLogged();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && !saving && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log delivery request</DialogTitle>
|
||||
<DialogDescription>
|
||||
What the client asked for. It stays under Awaiting delivery until every shot has been uploaded and the
|
||||
link shared.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{preset ? (
|
||||
<p className="text-sm text-zinc-300">
|
||||
{preset.shotIds.length} selected shot{preset.shotIds.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="request-project">Project</Label>
|
||||
<select
|
||||
id="request-project"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="">Select a project…</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4 text-sm text-zinc-200">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" checked={mode === "episode"} onChange={() => setMode("episode")} />
|
||||
Whole episode
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" checked={mode === "codes"} onChange={() => setMode("codes")} />
|
||||
Paste shot codes
|
||||
</label>
|
||||
</div>
|
||||
{mode === "episode" ? (
|
||||
<select
|
||||
aria-label="Episode"
|
||||
value={episode}
|
||||
onChange={(e) => setEpisode(e.target.value)}
|
||||
disabled={!projectId}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
<option value="">{projectId ? "Select an episode…" : "Pick a project first"}</option>
|
||||
{episodes.map((e) => (
|
||||
<option key={e.episode} value={e.episode}>
|
||||
{e.episode} — {e.shotCount} shots
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Textarea
|
||||
aria-label="Shot codes"
|
||||
value={codes}
|
||||
onChange={(e) => setCodes(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Paste from the client's message — our codes or theirs, any separator"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{codeCount} code{codeCount === 1 ? "" : "s"} found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="request-deliverable">Wanted</Label>
|
||||
<select
|
||||
id="request-deliverable"
|
||||
value={deliverable}
|
||||
onChange={(e) => setDeliverable(e.target.value as Deliverable)}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
{DELIVERABLES.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{DELIVERABLE_LABEL[d]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="request-due">Due (optional)</Label>
|
||||
<Input
|
||||
id="request-due"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="request-by">Asked by</Label>
|
||||
<Input
|
||||
id="request-by"
|
||||
placeholder="Who at the client asked"
|
||||
value={requestedBy}
|
||||
onChange={(e) => setRequestedBy(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="request-note">Note (optional)</Label>
|
||||
<Input id="request-note" value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400 break-words">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} disabled={!canSave}>
|
||||
Log request
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import { LogOut, User, Settings } from "lucide-react";
|
||||
import { LogOut, User, Settings, Menu } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMobileNav } from "@/components/layout/MobileNav";
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string;
|
||||
@@ -23,11 +24,21 @@ interface HeaderProps {
|
||||
export function Header({ title, breadcrumbs }: HeaderProps) {
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
const { setOpen: setMobileNavOpen } = useMobileNav();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-6">
|
||||
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-4 md:px-6">
|
||||
{/* Left: Title / Breadcrumbs */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Phones only: the sidebar is hidden below md and opens as a drawer. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
className="md:hidden -ml-2 rounded-md p-2 text-zinc-400 hover:bg-zinc-800 hover:text-white"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
{breadcrumbs ? (
|
||||
<nav className="flex items-center gap-1.5 text-sm">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user