feat(review): flag handles in the review player (metadata only)
Deploy / deploy (push) Successful in 3m31s
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:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user