Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
"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 } = 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);
// 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);
}
}
}
// 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]);
// 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;
}, []);
useEffect(() => {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
return (
<canvas
ref={canvasRef}
className="frame-timeline w-full cursor-ew-resize"
style={{ height: 48 }}
onMouseDown={handleMouseDown}
/>
);
}
+284
View File
@@ -0,0 +1,284 @@
"use client";
import { RefObject } from "react";
import {
Play,
Pause,
SkipBack,
SkipForward,
ChevronFirst,
ChevronLast,
Maximize2,
MessageSquarePlus,
Pencil,
Eye,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useReviewStore } from "@/hooks/use-review-player";
import { frameToTimecode } from "@/lib/frame-utils";
import { cn } from "@/lib/utils";
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
interface PlaybackControlsProps {
videoRef: RefObject<HTMLVideoElement | null>;
fps: number;
isReversing: boolean;
onStepBackward: () => void;
onStepForward: () => void;
onReverse: () => void;
onTogglePlay: () => void;
onToggleFullscreen: () => void;
onAddComment: () => void;
}
export function PlaybackControls({
videoRef,
fps,
isReversing,
onStepBackward,
onStepForward,
onReverse,
onTogglePlay,
onToggleFullscreen,
onAddComment,
}: PlaybackControlsProps) {
const {
isPlaying,
currentFrame,
currentTime,
totalFrames,
playbackRate,
isAnnotating,
showAnnotations,
setPlaybackRate,
setAnnotating,
setShowAnnotations,
} = useReviewStore();
const handleRateChange = (val: string) => {
const rate = parseFloat(val);
if (videoRef.current) videoRef.current.playbackRate = rate;
setPlaybackRate(rate);
};
const timecode = frameToTimecode(currentFrame, fps);
return (
<div className="flex items-center gap-2 bg-black/90 px-3 py-2 border-t border-white/5">
{/* Frame info */}
<div className="flex items-center gap-3 font-mono text-xs text-zinc-300 min-w-0">
<span className="hidden sm:block text-zinc-500">
{timecode}
</span>
<span className="text-white font-semibold">
F{String(currentFrame).padStart(4, "0")}
</span>
<span className="text-zinc-600 hidden md:block">
/ {totalFrames}
</span>
</div>
{/* Divider */}
<div className="h-5 w-px bg-white/10 mx-1" />
{/* Transport Controls */}
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => {
if (videoRef.current) videoRef.current.currentTime = 0;
}}
>
<ChevronFirst className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Go to start</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
isReversing && "text-amber-400 bg-amber-400/10"
)}
onClick={onReverse}
>
<SkipBack className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Reverse (J)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onStepBackward}
>
<SkipBack className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Step back ()</TooltipContent>
</Tooltip>
{/* Play/Pause — slightly larger */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-white hover:text-white hover:bg-white/10 h-9 w-9"
onClick={onTogglePlay}
>
{isPlaying ? (
<Pause className="h-5 w-5 fill-current" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Play / Pause (K or Space)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onStepForward}
>
<SkipForward className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Step forward ()</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => {
if (videoRef.current)
videoRef.current.currentTime = videoRef.current.duration;
}}
>
<ChevronLast className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Go to end</TooltipContent>
</Tooltip>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right Controls */}
<div className="flex items-center gap-2">
{/* Playback speed */}
<Select value={String(playbackRate)} onValueChange={handleRateChange}>
<SelectTrigger className="h-7 w-16 text-xs border-0 bg-white/5 text-zinc-300 px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLAYBACK_RATES.map((r) => (
<SelectItem key={r} value={String(r)} className="text-xs">
{r}x
</SelectItem>
))}
</SelectContent>
</Select>
{/* Annotation toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
isAnnotating && "text-amber-400 bg-amber-400/10"
)}
onClick={() => setAnnotating(!isAnnotating)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isAnnotating ? "Stop drawing" : "Draw annotation"}
</TooltipContent>
</Tooltip>
{/* Show/hide annotations */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => setShowAnnotations(!showAnnotations)}
>
{showAnnotations ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{showAnnotations ? "Hide annotations" : "Show annotations"}
</TooltipContent>
</Tooltip>
{/* Add Comment */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-primary"
onClick={onAddComment}
>
<MessageSquarePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add comment at frame {currentFrame}</TooltipContent>
</Tooltip>
{/* Fullscreen */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onToggleFullscreen}
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Fullscreen (F)</TooltipContent>
</Tooltip>
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
"use client";
import {
useRef,
useEffect,
useState,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
} from "react";
import { useReviewStore } from "@/hooks/use-review-player";
import { AnnotationCanvas } from "@/components/annotations/AnnotationCanvas";
import { AnnotationTools } from "@/components/annotations/AnnotationTools";
import { FrameTimeline } from "./FrameTimeline";
import { PlaybackControls } from "./PlaybackControls";
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
import { cn } from "@/lib/utils";
import type { CommentWithReplies } from "@/types";
export interface ReviewPlayerRef {
seekToFrame: (frame: number) => void;
play: () => void;
pause: () => void;
}
interface ReviewPlayerProps {
videoUrl: string;
versionId: string;
fps?: number;
comments?: CommentWithReplies[];
annotations?: unknown[];
className?: string;
onAddComment?: (frameNumber: number, timestamp: number) => void;
onAnnotationSaved?: (frameNumber: number) => void;
}
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
function ReviewPlayer(
{
videoUrl,
versionId,
fps = 24,
comments = [],
annotations,
className,
onAddComment,
onAnnotationSaved,
},
ref
) {
// Stabilise the annotations reference so AnnotationCanvas's sync effect
// only fires when the actual content changes, not on every parent re-render.
const stableAnnotations = useMemo(() => annotations ?? [], [annotations]);
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [isReversing, setIsReversing] = useState(false);
const {
isPlaying,
currentFrame,
isAnnotating,
showAnnotations,
setPlaying,
setCurrentFrame,
setCurrentTime,
setDuration,
setFps,
setTotalFrames,
} = useReviewStore();
// Initialize player config from props
useEffect(() => {
setFps(fps);
}, [fps, setFps]);
// ── Playback state sync ──────────────────────────────────────────────────
const handleTimeUpdate = useCallback(() => {
const video = videoRef.current;
if (!video) return;
const frame = timeToFrame(video.currentTime, fps);
setCurrentFrame(frame);
setCurrentTime(video.currentTime);
}, [fps, setCurrentFrame, setCurrentTime]);
const handleLoadedMetadata = useCallback(() => {
const video = videoRef.current;
if (!video) return;
const frames = durationToFrameCount(video.duration, fps);
setDuration(video.duration);
setTotalFrames(frames);
}, [fps, setDuration, setTotalFrames]);
const handlePlay = useCallback(() => setPlaying(true), [setPlaying]);
const handlePause = useCallback(() => setPlaying(false), [setPlaying]);
// ── Exposed ref API ──────────────────────────────────────────────────────
useImperativeHandle(ref, () => ({
seekToFrame: (frame: number) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = frameToTime(frame, fps);
if (!video.paused) video.pause();
},
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
}));
// ── Frame step functions ─────────────────────────────────────────────────
const stepFrame = useCallback(
(delta: number) => {
const video = videoRef.current;
if (!video) return;
video.pause();
stopReverse();
const newTime = Math.max(0, Math.min(video.duration, video.currentTime + delta / fps));
video.currentTime = newTime;
},
[fps]
);
const stopReverse = useCallback(() => {
if (reverseIntervalRef.current) {
clearInterval(reverseIntervalRef.current);
reverseIntervalRef.current = null;
}
setIsReversing(false);
}, []);
const startReverse = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.pause();
setIsReversing(true);
reverseIntervalRef.current = setInterval(() => {
if (!videoRef.current || videoRef.current.currentTime <= 0) {
stopReverse();
return;
}
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 1 / fps);
}, 1000 / fps);
}, [fps, stopReverse]);
const togglePlayback = useCallback(() => {
const video = videoRef.current;
if (!video) return;
stopReverse();
if (video.paused) {
video.play();
} else {
video.pause();
}
}, [stopReverse]);
// ── Keyboard shortcuts (JKL + arrows + space + F) ───────────────────────
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
)
return;
switch (e.key.toLowerCase()) {
case "j":
e.preventDefault();
if (isReversing) {
stopReverse();
} else {
startReverse();
}
break;
case "k":
e.preventDefault();
if (isReversing) {
stopReverse();
} else {
togglePlayback();
}
break;
case "l":
e.preventDefault();
stopReverse();
if (videoRef.current?.paused) videoRef.current.play();
break;
case "arrowleft":
e.preventDefault();
stepFrame(-1);
break;
case "arrowright":
e.preventDefault();
stepFrame(1);
break;
case " ":
e.preventDefault();
stopReverse();
togglePlayback();
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
stopReverse();
};
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback]);
// ── Fullscreen ───────────────────────────────────────────────────────────
const toggleFullscreen = () => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
};
const handleSeek = useCallback(
(frame: number) => {
const video = videoRef.current;
if (!video) return;
stopReverse();
video.currentTime = frameToTime(frame, fps);
video.pause();
},
[fps, stopReverse]
);
const handleAddComment = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.pause();
onAddComment?.(currentFrame, video.currentTime);
}, [currentFrame, onAddComment]);
return (
<div
ref={containerRef}
className={cn(
"review-player-container flex flex-col bg-black select-none h-full",
className
)}
>
{/* Video */}
<div className="relative flex-1 min-h-0 overflow-hidden">
<video
ref={videoRef}
src={videoUrl}
className="block w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={handlePlay}
onPause={handlePause}
preload="metadata"
playsInline
/>
{/* Annotation canvas overlay */}
<AnnotationCanvas
versionId={versionId}
frameNumber={currentFrame}
fps={fps}
isAnnotating={isAnnotating && !isPlaying}
showAnnotations={showAnnotations}
existingAnnotations={stableAnnotations}
onAnnotationSaved={onAnnotationSaved}
/>
{/* JKL hint overlay — shown briefly when reversing */}
{isReversing && (
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
REVERSE
</div>
)}
</div>
{/* Annotation Tools (visible when annotation mode is active) */}
<AnnotationTools />
{/* Frame Timeline */}
<FrameTimeline
fps={fps}
comments={comments}
annotations={annotations as { frameNumber: number }[]}
videoRef={videoRef}
onSeek={handleSeek}
/>
{/* Playback Controls */}
<PlaybackControls
videoRef={videoRef}
fps={fps}
isReversing={isReversing}
onStepBackward={() => stepFrame(-1)}
onStepForward={() => stepFrame(1)}
onReverse={isReversing ? stopReverse : startReverse}
onTogglePlay={togglePlayback}
onToggleFullscreen={toggleFullscreen}
onAddComment={handleAddComment}
/>
</div>
);
}
);