feat(review): flag handles in the review player (metadata only)
Deploy / deploy (push) Successful in 3m31s

Renders run 8 frames past the cut at each end. Directors were flagging
glitches in unfixed handles as shot problems, so the player now marks
them — without trimming or burning anything into the EXRs/MOV/MP4.

- Version.handleHead/handleTail/pictureStartFrame, Export.handleHead/
  handleTail; migration backfills pipeline exports and their preview
  versions from the manifest's handleFrames (else 8)
- AE panel: "Includes handles [8] frames each end" -> manifest.review;
  older manifests fall back to source.handleFrames
- finalize copies handles onto the Version; worker 1.6.4 reports the
  head slate length (pictureStartSeconds), else the player infers it
  as MP4 frames - EXR frameCount
- Player: hatched HANDLE / SLATE regions and cut marks on the timeline,
  "HEAD/TAIL HANDLE · NOT IN EDIT" overlay on handle frames, HANDLE badge
  on notes, "Edit range only" toggle (default on in the client portal)
- Staff Handles editor on the review page for manual uploads/corrections

Also fixes two player bugs found while testing: timeToFrame floored
float error (every seek landed a frame early), and J/reverse never ran
(the keyboard effect's cleanup stopped it on the next render).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-22 15:07:29 +02:00
parent 2817c7e815
commit 2e9fa45580
27 changed files with 938 additions and 23 deletions
+63 -2
View File
@@ -16,7 +16,7 @@ interface FrameTimelineProps {
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDragging = useRef(false);
const { currentFrame, totalFrames } = useReviewStore();
const { currentFrame, totalFrames, editRange } = useReviewStore();
const draw = useCallback(() => {
const canvas = canvasRef.current;
@@ -32,6 +32,42 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
ctx.fillStyle = "hsl(0 0% 6%)";
ctx.fillRect(0, 0, W, H);
// Slate and handles — under the ruler so ticks still read across them
const regions: { from: number; to: number; kind: "slate" | "handle" }[] = [];
if (editRange && totalFrames > 0) {
const { pictureStart, cutIn, tailStart, pictureEnd } = editRange;
if (pictureStart > 0) regions.push({ from: 0, to: pictureStart, kind: "slate" });
if (cutIn > pictureStart) regions.push({ from: pictureStart, to: cutIn, kind: "handle" });
if (pictureEnd > tailStart) regions.push({ from: tailStart, to: pictureEnd, kind: "handle" });
}
const xOf = (f: number) => Math.round((f / totalFrames) * W);
for (const r of regions) {
const x0 = xOf(r.from);
const x1 = xOf(r.to);
if (x1 <= x0) continue;
if (r.kind === "slate") {
ctx.fillStyle = "hsl(0 0% 2%)";
ctx.fillRect(x0, 0, x1 - x0, H);
continue;
}
ctx.fillStyle = "hsl(38 92% 50% / 0.14)";
ctx.fillRect(x0, 0, x1 - x0, H);
// Diagonal hatching, clipped to the region
ctx.save();
ctx.beginPath();
ctx.rect(x0, 0, x1 - x0, H);
ctx.clip();
ctx.strokeStyle = "hsl(38 92% 50% / 0.28)";
ctx.lineWidth = 1;
for (let x = x0 - H; x < x1; x += 7) {
ctx.beginPath();
ctx.moveTo(x, H);
ctx.lineTo(x + H, 0);
ctx.stroke();
}
ctx.restore();
}
// Frame ruler ticks
if (totalFrames > 0) {
const tickInterval = Math.max(1, Math.floor(totalFrames / (W / 40)));
@@ -59,6 +95,31 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
}
}
// Region labels and cut points, over the ruler
for (const r of regions) {
const x0 = xOf(r.from);
const x1 = xOf(r.to);
const label = r.kind === "slate" ? "SLATE" : "HANDLE";
ctx.font = "bold 9px monospace";
if (x1 - x0 >= ctx.measureText(label).width + 8) {
ctx.fillStyle = r.kind === "slate" ? "hsl(0 0% 45%)" : "hsl(38 92% 60%)";
ctx.textAlign = "center";
ctx.fillText(label, (x0 + x1) / 2, H / 2 + 8);
}
}
if (editRange && totalFrames > 0) {
ctx.strokeStyle = "hsl(38 92% 55%)";
ctx.lineWidth = 1;
for (const f of [editRange.cutIn, editRange.tailStart]) {
if (f <= editRange.pictureStart || f >= editRange.pictureEnd) continue;
const x = xOf(f) + 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, H);
ctx.stroke();
}
}
// Annotation markers — amber diamonds at top
const seenAnnotationFrames = new Set<number>();
annotations.forEach((ann) => {
@@ -121,7 +182,7 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
ctx.closePath();
ctx.fill();
}
}, [currentFrame, totalFrames, comments, annotations]);
}, [currentFrame, totalFrames, comments, annotations, editRange]);
// Resize observer to match canvas to container
useEffect(() => {