2e9fa45580
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>
291 lines
9.0 KiB
TypeScript
291 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useEffect, useCallback, RefObject } from "react";
|
|
import { useReviewStore } from "@/hooks/use-review-player";
|
|
import { frameToPosition } from "@/lib/frame-utils";
|
|
import type { CommentWithReplies } from "@/types";
|
|
|
|
interface FrameTimelineProps {
|
|
fps: number;
|
|
comments: CommentWithReplies[];
|
|
annotations?: { frameNumber: number }[];
|
|
videoRef: RefObject<HTMLVideoElement | null>;
|
|
onSeek: (frame: number) => void;
|
|
}
|
|
|
|
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const isDragging = useRef(false);
|
|
const { currentFrame, totalFrames, editRange } = useReviewStore();
|
|
|
|
const draw = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
const W = canvas.width;
|
|
const H = canvas.height;
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
// Background
|
|
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)));
|
|
|
|
ctx.strokeStyle = "hsl(0 0% 20%)";
|
|
ctx.lineWidth = 1;
|
|
ctx.fillStyle = "hsl(0 0% 35%)";
|
|
ctx.font = "9px monospace";
|
|
ctx.textAlign = "center";
|
|
|
|
for (let f = 0; f <= totalFrames; f += tickInterval) {
|
|
const x = Math.round((f / totalFrames) * W);
|
|
const isMajor = f % (tickInterval * 5) === 0 || tickInterval > 20;
|
|
|
|
ctx.strokeStyle = isMajor ? "hsl(0 0% 22%)" : "hsl(0 0% 16%)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, isMajor ? 0 : H / 2);
|
|
ctx.lineTo(x, H);
|
|
ctx.stroke();
|
|
|
|
if (isMajor && f > 0) {
|
|
ctx.fillStyle = "hsl(0 0% 35%)";
|
|
ctx.fillText(String(f), x, 11);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) => {
|
|
if (totalFrames === 0) return;
|
|
if (seenAnnotationFrames.has(ann.frameNumber)) return;
|
|
seenAnnotationFrames.add(ann.frameNumber);
|
|
const x = Math.round(frameToPosition(ann.frameNumber, totalFrames) * W);
|
|
// Amber diamond
|
|
ctx.fillStyle = "hsl(38 92% 50%)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 2);
|
|
ctx.lineTo(x + 4, 7);
|
|
ctx.lineTo(x, 12);
|
|
ctx.lineTo(x - 4, 7);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
});
|
|
|
|
// Comment markers — blue triangles pointing down from top
|
|
comments.forEach((comment) => {
|
|
if (totalFrames === 0) return;
|
|
const x = Math.round(frameToPosition(comment.frameNumber, totalFrames) * W);
|
|
|
|
if (comment.isResolved) {
|
|
ctx.fillStyle = "hsl(142 71% 45% / 0.7)";
|
|
} else {
|
|
ctx.fillStyle = "hsl(213 94% 68%)";
|
|
}
|
|
|
|
// Triangle marker pointing down from top
|
|
ctx.beginPath();
|
|
ctx.moveTo(x - 4, 0);
|
|
ctx.lineTo(x + 4, 0);
|
|
ctx.lineTo(x, 8);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
});
|
|
|
|
// Playhead
|
|
if (totalFrames > 0) {
|
|
const playheadX = Math.round(
|
|
frameToPosition(currentFrame, totalFrames) * W
|
|
);
|
|
|
|
// Red line
|
|
ctx.strokeStyle = "hsl(0 72% 51%)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(playheadX, 0);
|
|
ctx.lineTo(playheadX, H);
|
|
ctx.stroke();
|
|
|
|
// Red diamond at top
|
|
ctx.fillStyle = "hsl(0 72% 51%)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(playheadX, H - 4);
|
|
ctx.lineTo(playheadX - 5, H - 10);
|
|
ctx.lineTo(playheadX, H - 16);
|
|
ctx.lineTo(playheadX + 5, H - 10);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
}, [currentFrame, totalFrames, comments, annotations, editRange]);
|
|
|
|
// Resize observer to match canvas to container
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
canvas.width = canvas.offsetWidth;
|
|
canvas.height = canvas.offsetHeight;
|
|
draw();
|
|
});
|
|
observer.observe(canvas);
|
|
return () => observer.disconnect();
|
|
}, [draw]);
|
|
|
|
useEffect(() => {
|
|
draw();
|
|
}, [draw]);
|
|
|
|
// Seek on click/drag
|
|
const getFrameFromEvent = useCallback(
|
|
(e: React.MouseEvent | MouseEvent): number => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas || totalFrames === 0) return 0;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
|
return Math.round((x / rect.width) * totalFrames);
|
|
},
|
|
[totalFrames]
|
|
);
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
isDragging.current = true;
|
|
onSeek(getFrameFromEvent(e));
|
|
};
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: MouseEvent) => {
|
|
if (!isDragging.current) return;
|
|
onSeek(getFrameFromEvent(e));
|
|
},
|
|
[getFrameFromEvent, onSeek]
|
|
);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
isDragging.current = false;
|
|
}, []);
|
|
|
|
// ── Touch scrubbing ──────────────────────────────────────────────────────
|
|
const getFrameFromTouch = useCallback(
|
|
(touch: { clientX: number }): number => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas || totalFrames === 0) return 0;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = Math.max(0, Math.min(touch.clientX - rect.left, rect.width));
|
|
return Math.round((x / rect.width) * totalFrames);
|
|
},
|
|
[totalFrames]
|
|
);
|
|
|
|
const handleTouchStart = useCallback(
|
|
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
if (!touch) return;
|
|
isDragging.current = true;
|
|
onSeek(getFrameFromTouch(touch));
|
|
},
|
|
[getFrameFromTouch, onSeek]
|
|
);
|
|
|
|
const handleTouchMove = useCallback(
|
|
(e: TouchEvent) => {
|
|
if (!isDragging.current) return;
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
if (!touch) return;
|
|
onSeek(getFrameFromTouch(touch));
|
|
},
|
|
[getFrameFromTouch, onSeek]
|
|
);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener("mousemove", handleMouseMove);
|
|
window.addEventListener("mouseup", handleMouseUp);
|
|
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
window.addEventListener("touchend", handleMouseUp);
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("mouseup", handleMouseUp);
|
|
window.removeEventListener("touchmove", handleTouchMove);
|
|
window.removeEventListener("touchend", handleMouseUp);
|
|
};
|
|
}, [handleMouseMove, handleMouseUp, handleTouchMove]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="frame-timeline w-full cursor-ew-resize"
|
|
style={{ height: 48, touchAction: "none" }}
|
|
onMouseDown={handleMouseDown}
|
|
onTouchStart={handleTouchStart}
|
|
/>
|
|
);
|
|
}
|