Eyedropper comparison added for visual review tool. Needs to be tested and finessed on workstation

This commit is contained in:
Leivur R. Djurhuus 2026-03-15 22:22:13 -05:00 committed by Leivur Djurhuus
parent 43051792a3
commit bd69208a84
16 changed files with 1457 additions and 29 deletions

View file

@ -148,7 +148,7 @@ model User {
feedbackAssigned FeedbackItem[] @relation("FeedbackAssignee")
feedbackResolved FeedbackItem[] @relation("FeedbackResolver")
feedbackVerified FeedbackItem[] @relation("FeedbackVerifier")
colorProbes ColorProbe[]
colorProbes ColorProbe[] @relation("ColorProbeCreator")
reviewSessionsCreated ReviewSession[] @relation("ReviewSessionCreator")
reviewSessionDecisions ReviewSessionItem[] @relation("ReviewSessionDecider")
@ -744,27 +744,6 @@ model Annotation {
@@map("annotations")
}
// ─── Color Probe ────────────────────────────────────────
model ColorProbe {
id String @id @default(cuid())
revisionId String
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
index Int
workingX Float
workingY Float
referenceX Float
referenceY Float
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([revisionId, index])
@@index([revisionId])
@@map("color_probes")
}
// ─── Feedback Item ──────────────────────────────────────
model FeedbackItem {
@ -855,3 +834,24 @@ model ReviewSessionItem {
@@index([sessionId])
@@map("review_session_items")
}
// ─── Color Probes (CMF Eyedropper) ─────────────────────
model ColorProbe {
id String @id @default(cuid())
revisionId String
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
index Int // 1-12, display order
workingX Float // image-space coordinates on working image
workingY Float
referenceX Float // image-space coordinates on reference (defaults to same as working)
referenceY Float
createdById String
createdBy User @relation("ColorProbeCreator", fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([revisionId, index])
@@index([revisionId])
@@map("color_probes")
}

View file

@ -227,6 +227,18 @@ export default function ReviewPage() {
return match?.revisionId ?? null;
}, [galleryImages, activeImageUrl]);
// ── Image URLs for CMF probe sampling ──────────────────────────────
const { workingImageUrl, referenceImageUrl } = useMemo(() => {
if (!activeRevisionId) return { workingImageUrl: null, referenceImageUrl: null };
const rev = revisions.find((r: any) => r.id === activeRevisionId);
if (!rev) return { workingImageUrl: null, referenceImageUrl: null };
const att = rev.attachments as RevisionAttachments | null;
return {
workingImageUrl: att?.currentImage?.url ?? null,
referenceImageUrl: att?.referenceImage?.url ?? null,
};
}, [activeRevisionId, revisions]);
// ── Delete annotation (from feedback sidebar) ─────────────────────
const deleteAnnotationMutation = useDeleteAnnotation(activeRevisionId);
const handleDeleteAnnotation = useCallback(
@ -549,6 +561,8 @@ export default function ReviewPage() {
containerHeight={vs.containerHeight}
imageDimensions={vs.imageDimensions}
hoveredAnnotationId={hoveredAnnotationId}
workingImageUrl={workingImageUrl}
referenceImageUrl={referenceImageUrl}
/>
)}
/>

View file

@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
import { updateColorProbeSchema } from "@/lib/validators/color-probe";
import {
updateColorProbe,
deleteColorProbe,
} from "@/lib/services/color-probe-service";
interface RouteContext {
params: Promise<{ revisionId: string; probeId: string }>;
}
export async function PATCH(req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const { probeId } = await ctx.params;
const body = await req.json();
const parsed = updateColorProbeSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.message);
}
const probe = await updateColorProbe(probeId, parsed.data);
return NextResponse.json(probe);
} catch (err) {
return serverError(err);
}
}
export async function DELETE(_req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const { probeId } = await ctx.params;
await deleteColorProbe(probeId);
return NextResponse.json({ ok: true });
} catch (err) {
return serverError(err);
}
}

View file

@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
import { prisma } from "@/lib/prisma";
import { createColorProbeSchema } from "@/lib/validators/color-probe";
import {
listColorProbes,
createColorProbe,
clearColorProbes,
} from "@/lib/services/color-probe-service";
interface RouteContext {
params: Promise<{ revisionId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const { revisionId } = await ctx.params;
const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
if (!revision) return notFound("Revision not found");
const probes = await listColorProbes(revisionId);
return NextResponse.json(probes);
} catch (err) {
return serverError(err);
}
}
export async function POST(req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const { revisionId } = await ctx.params;
const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
if (!revision) return notFound("Revision not found");
const body = await req.json();
const parsed = createColorProbeSchema.safeParse(body);
if (!parsed.success) {
return badRequest(parsed.error.message);
}
const probe = await createColorProbe(revisionId, session!.user.id, parsed.data);
return NextResponse.json(probe, { status: 201 });
} catch (err) {
return serverError(err);
}
}
export async function DELETE(_req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
if (error) return error;
const { revisionId } = await ctx.params;
await clearColorProbes(revisionId);
return NextResponse.json({ ok: true });
} catch (err) {
return serverError(err);
}
}

View file

@ -37,7 +37,7 @@ export async function POST(request: Request, { params }: Params) {
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
}
const revision = await createRevision(stageId, parsed.data);
const revision = await createRevision(stageId, parsed.data, session!.user.id);
return NextResponse.json(revision, { status: 201 });
} catch (e) {
return serverError(e);

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useCallback, useRef } from "react";
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
import { Send, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -9,6 +9,7 @@ import {
type AnnotationShape,
} from "@/components/review/annotation-renderer";
import { AnnotationTools } from "@/components/review/annotation-tools";
import { ColorProbeLayer } from "@/components/review/color-probe-layer";
import { useAnnotationState } from "@/hooks/use-annotation-state";
// ── Types ──────────────────────────────────────────────
@ -25,6 +26,10 @@ export interface AnnotationLayerProps {
readOnly?: boolean;
/** ID of annotation to highlight (when hovering feedback item) */
hoveredAnnotationId?: string | null;
/** Working image URL for CMF probe sampling */
workingImageUrl?: string | null;
/** Reference image URL for CMF probe sampling */
referenceImageUrl?: string | null;
}
// ── Component ──────────────────────────────────────────
@ -40,8 +45,11 @@ export function AnnotationLayer({
imageDimensions,
readOnly = false,
hoveredAnnotationId,
workingImageUrl,
referenceImageUrl,
}: AnnotationLayerProps) {
const ann = useAnnotationState(revisionId, stageId);
const [probesVisible, setProbesVisible] = useState(true);
// Drag-to-move annotations — works in select mode, dragging any annotation
const handleAnnotationDragStart = useCallback(
@ -141,6 +149,8 @@ export function AnnotationLayer({
onToggleVisibility={() => ann.setVisible((v: boolean) => !v)}
hasSelection={!!ann.selectedId}
onDeleteSelection={ann.handleDeleteSelection}
probesVisible={probesVisible}
onToggleProbes={() => setProbesVisible((v) => !v)}
/>
</div>
</div>
@ -362,6 +372,22 @@ export function AnnotationLayer({
</div>
)}
{/* ── CMF Color Probe Layer ────────────────────── */}
{!readOnly && (
<ColorProbeLayer
revisionId={revisionId}
active={ann.activeTool === "eyedropper"}
visible={probesVisible}
zoom={zoom}
panX={panX}
panY={panY}
containerWidth={containerWidth}
containerHeight={containerHeight}
workingImageUrl={workingImageUrl ?? null}
referenceImageUrl={referenceImageUrl ?? null}
/>
)}
{/* ── Annotation comment popover ────────────────── */}
{!readOnly && ann.pendingAnnotation && (
<div

View file

@ -7,6 +7,7 @@ import {
Pencil,
Type,
MapPin,
Pipette,
Undo2,
Redo2,
Eye,
@ -34,7 +35,8 @@ export type AnnotationTool =
| "arrow"
| "freehand"
| "text"
| "pin";
| "pin"
| "eyedropper";
const PRESET_COLORS = [
{ label: "Red", value: "#EE5540" },
@ -60,6 +62,9 @@ interface AnnotationToolsProps {
onToggleVisibility: () => void;
hasSelection: boolean;
onDeleteSelection: () => void;
/** CMF eyedropper probe visibility toggle */
probesVisible?: boolean;
onToggleProbes?: () => void;
}
const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?: string }[] = [
@ -69,6 +74,7 @@ const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?
{ id: "freehand", icon: Pencil, label: "Freehand", shortcut: "F" },
{ id: "text", icon: Type, label: "Text label", shortcut: "T" },
{ id: "pin", icon: MapPin, label: "Pin", shortcut: "P" },
{ id: "eyedropper", icon: Pipette, label: "CMF Eyedropper", shortcut: "D" },
];
export function AnnotationTools({
@ -84,6 +90,8 @@ export function AnnotationTools({
onToggleVisibility,
hasSelection,
onDeleteSelection,
probesVisible,
onToggleProbes,
}: AnnotationToolsProps) {
return (
<div className="flex items-center gap-1">
@ -217,6 +225,31 @@ export function AnnotationTools({
</TooltipContent>
</Tooltip>
{/* CMF probes visibility toggle */}
{onToggleProbes && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className={cn(
"h-7 w-7 p-0",
probesVisible && "text-[var(--primary)]"
)}
onClick={onToggleProbes}
>
<Pipette className={cn(
"h-3.5 w-3.5",
!probesVisible && "text-[var(--muted-foreground)]"
)} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{probesVisible ? "Hide CMF probes" : "Show CMF probes"}
</TooltipContent>
</Tooltip>
)}
{/* Delete selection */}
{hasSelection && (
<Tooltip>

View file

@ -0,0 +1,341 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ColorProbeMarker, ColorProbeGhostMarker } from "@/components/review/color-probe-marker";
import { ColorProbePanel } from "@/components/review/color-probe-panel";
import {
useColorProbes,
useCreateColorProbe,
useUpdateColorProbe,
useDeleteColorProbe,
useClearColorProbes,
} from "@/hooks/use-color-probes";
import {
rgbToHsb,
compareHsb,
sampleFromImage,
type ProbeResult,
} from "@/lib/utils/color";
export interface ColorProbeLayerProps {
revisionId: string | null;
/** Whether the eyedropper tool is active */
active: boolean;
/** Whether probes are visible */
visible: boolean;
/** Viewport state */
zoom: number;
panX: number;
panY: number;
containerWidth: number;
containerHeight: number;
/** Working image URL (the current render) */
workingImageUrl: string | null;
/** Reference image URL */
referenceImageUrl: string | null;
}
const MAX_PROBES = 12;
export function ColorProbeLayer({
revisionId,
active,
visible,
zoom,
panX,
panY,
containerWidth,
containerHeight,
workingImageUrl,
referenceImageUrl,
}: ColorProbeLayerProps) {
const { data: probes = [] } = useColorProbes(revisionId);
const createProbe = useCreateColorProbe(revisionId);
const updateProbe = useUpdateColorProbe(revisionId);
const deleteProbe = useDeleteColorProbe(revisionId);
const clearProbes = useClearColorProbes(revisionId);
const [selectedProbeId, setSelectedProbeId] = useState<string | null>(null);
// Loaded image refs for sampling
const workingImgRef = useRef<HTMLImageElement | null>(null);
const referenceImgRef = useRef<HTMLImageElement | null>(null);
// Load working image
useEffect(() => {
if (!workingImageUrl) {
workingImgRef.current = null;
return;
}
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => { workingImgRef.current = img; };
img.src = workingImageUrl;
}, [workingImageUrl]);
// Load reference image
useEffect(() => {
if (!referenceImageUrl) {
referenceImgRef.current = null;
return;
}
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => { referenceImgRef.current = img; };
img.src = referenceImageUrl;
}, [referenceImageUrl]);
// Compute probe results by sampling both images
const probeResults = useMemo(() => {
const results = new Map<number, ProbeResult>();
const workingImg = workingImgRef.current;
const refImg = referenceImgRef.current;
if (!workingImg || !refImg) return results;
for (const probe of probes) {
const workingSample = sampleFromImage(workingImg, probe.workingX, probe.workingY);
const refSample = sampleFromImage(refImg, probe.referenceX, probe.referenceY);
if (workingSample && refSample) {
const workingHsb = rgbToHsb(workingSample);
const refHsb = rgbToHsb(refSample);
results.set(probe.index, compareHsb(workingHsb, refHsb));
}
}
return results;
// Re-sample whenever probes or images change
}, [probes, workingImageUrl, referenceImageUrl]); // eslint-disable-line react-hooks/exhaustive-deps
// Handle click to place a new probe
const handleClick = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (!active || !revisionId) return;
if (probes.length >= MAX_PROBES) return;
const svg = e.currentTarget;
const rect = svg.getBoundingClientRect();
const imgX = (e.clientX - rect.left - panX) / zoom;
const imgY = (e.clientY - rect.top - panY) / zoom;
// Find next available index
const usedIndices = new Set(probes.map((p) => p.index));
let nextIndex = 1;
while (usedIndices.has(nextIndex) && nextIndex <= MAX_PROBES) {
nextIndex++;
}
if (nextIndex > MAX_PROBES) return;
createProbe.mutate({
index: nextIndex,
workingX: Math.round(imgX),
workingY: Math.round(imgY),
referenceX: Math.round(imgX),
referenceY: Math.round(imgY),
});
},
[active, revisionId, probes, panX, panY, zoom, createProbe]
);
// Drag handler for working-side probe
const handleProbeDrag = useCallback(
(probeId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setSelectedProbeId(probeId);
const probe = probes.find((p) => p.id === probeId);
if (!probe) return;
let lastX = e.clientX;
let lastY = e.clientY;
const handleMove = (me: MouseEvent) => {
const dx = (me.clientX - lastX) / zoom;
const dy = (me.clientY - lastY) / zoom;
lastX = me.clientX;
lastY = me.clientY;
// We update the offset relative to original — accumulate in a ref is better
// but for simplicity, we do a debounced update on mouseup
probe.workingX += dx;
probe.workingY += dy;
// Also move reference if not Alt-dragged
if (!me.altKey) {
probe.referenceX += dx;
probe.referenceY += dy;
}
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
updateProbe.mutate({
probeId,
data: {
workingX: Math.round(probe.workingX),
workingY: Math.round(probe.workingY),
referenceX: Math.round(probe.referenceX),
referenceY: Math.round(probe.referenceY),
},
});
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
[probes, zoom, updateProbe]
);
// Drag handler for reference-side ghost marker (Alt+drag to offset)
const handleRefDrag = useCallback(
(probeId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const probe = probes.find((p) => p.id === probeId);
if (!probe) return;
let lastX = e.clientX;
let lastY = e.clientY;
const handleMove = (me: MouseEvent) => {
const dx = (me.clientX - lastX) / zoom;
const dy = (me.clientY - lastY) / zoom;
lastX = me.clientX;
lastY = me.clientY;
probe.referenceX += dx;
probe.referenceY += dy;
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
updateProbe.mutate({
probeId,
data: {
referenceX: Math.round(probe.referenceX),
referenceY: Math.round(probe.referenceY),
},
});
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
[probes, zoom, updateProbe]
);
// Delete probe on right-click
const handleContextMenu = useCallback(
(probeId: string, e: React.MouseEvent) => {
e.preventDefault();
deleteProbe.mutate(probeId);
},
[deleteProbe]
);
// Delete key handler
useEffect(() => {
if (!selectedProbeId) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Delete" || e.key === "Backspace") {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
deleteProbe.mutate(selectedProbeId);
setSelectedProbeId(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedProbeId, deleteProbe]);
if (!visible || !revisionId) return null;
// Check if any reference point is offset from working point
const hasOffsets = probes.some(
(p) =>
Math.abs(p.referenceX - p.workingX) > 1 ||
Math.abs(p.referenceY - p.workingY) > 1
);
return (
<>
{/* SVG overlay for probe markers */}
<svg
className="absolute inset-0 z-22"
width={containerWidth}
height={containerHeight}
style={{
pointerEvents: active ? "auto" : "none",
cursor: active ? "crosshair" : "default",
}}
onClick={handleClick}
>
<g transform={`translate(${panX}, ${panY}) scale(${zoom})`}>
{/* Ghost markers for offset reference points */}
{hasOffsets &&
probes.map((probe) => {
const isOffset =
Math.abs(probe.referenceX - probe.workingX) > 1 ||
Math.abs(probe.referenceY - probe.workingY) > 1;
if (!isOffset) return null;
return (
<g key={`ref-${probe.id}`} onContextMenu={(e) => e.preventDefault()}>
{/* Connecting line */}
<line
x1={probe.workingX}
y1={probe.workingY}
x2={probe.referenceX}
y2={probe.referenceY}
stroke="rgba(255,255,255,0.25)"
strokeWidth={1 / zoom}
strokeDasharray={`${3 / zoom} ${2 / zoom}`}
/>
<ColorProbeGhostMarker
index={probe.index}
x={probe.referenceX}
y={probe.referenceY}
onDragStart={(e) => handleRefDrag(probe.id, e)}
/>
</g>
);
})}
{/* Working-side probe markers */}
{probes.map((probe) => (
<g
key={probe.id}
onContextMenu={(e) => handleContextMenu(probe.id, e)}
>
<ColorProbeMarker
index={probe.index}
x={probe.workingX}
y={probe.workingY}
result={probeResults.get(probe.index) ?? null}
isSelected={selectedProbeId === probe.id}
onDragStart={(e) => handleProbeDrag(probe.id, e)}
/>
</g>
))}
</g>
</svg>
{/* Summary panel — bottom-left */}
{probes.length > 0 && (
<div className="absolute bottom-3 left-3 z-30">
<ColorProbePanel
probeResults={probeResults}
probeCount={probes.length}
onClearAll={() => clearProbes.mutate()}
isClearing={clearProbes.isPending}
/>
</div>
)}
</>
);
}

View file

@ -0,0 +1,284 @@
"use client";
import { useState } from "react";
import type { ProbeResult } from "@/lib/utils/color";
interface ColorProbeMarkerProps {
index: number;
x: number;
y: number;
result: ProbeResult | null;
isSelected?: boolean;
onDragStart?: (e: React.MouseEvent) => void;
}
const STATUS_COLORS = {
pass: "#22C55E",
warn: "#F97316",
fail: "#EF4444",
unknown: "#6B7280",
} as const;
const MARKER_RADIUS = 8;
export function ColorProbeMarker({
index,
x,
y,
result,
isSelected,
onDragStart,
}: ColorProbeMarkerProps) {
const [hovered, setHovered] = useState(false);
const status = result?.status ?? "unknown";
const color = STATUS_COLORS[status];
return (
<g
transform={`translate(${x}, ${y})`}
style={{ cursor: "grab" }}
pointerEvents="auto"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseDown={onDragStart}
>
{/* Outer ring */}
<circle
r={MARKER_RADIUS + 2}
fill="none"
stroke={isSelected ? "#fff" : "rgba(0,0,0,0.5)"}
strokeWidth={isSelected ? 2 : 1}
/>
{/* Background circle */}
<circle
r={MARKER_RADIUS}
fill="#1a1a1a"
stroke={color}
strokeWidth={2}
/>
{/* Index number */}
<text
textAnchor="middle"
dominantBaseline="central"
fontSize="9"
fontWeight="700"
fontFamily="monospace"
fill="#fff"
style={{ userSelect: "none" }}
>
{index}
</text>
{/* Status pill — positioned to the right */}
<g transform="translate(16, 0)">
{/* Compact pill (always visible) */}
<rect
x={0}
y={-6}
width={12}
height={12}
rx={6}
fill={color}
opacity={0.9}
/>
{/* Expanded detail panel on hover */}
{hovered && result && (
<g transform="translate(18, -42)">
{/* Panel background */}
<rect
x={0}
y={0}
width={148}
height={72}
rx={4}
fill="rgba(15,15,15,0.92)"
stroke="rgba(255,255,255,0.12)"
strokeWidth={1}
/>
{/* Header */}
<text
x={8}
y={14}
fontSize="8"
fontWeight="700"
fontFamily="monospace"
letterSpacing="0.08em"
fill="rgba(255,255,255,0.5)"
>
PROBE {index}
</text>
<rect
x={52}
y={6}
width={32}
height={12}
rx={6}
fill={color}
opacity={0.85}
/>
<text
x={68}
y={15}
textAnchor="middle"
fontSize="7"
fontWeight="700"
fontFamily="monospace"
fill="#fff"
>
{status.toUpperCase()}
</text>
{/* H row */}
<HsbRow
label="H"
working={`${result.working.h}°`}
reference={`${result.reference.h}°`}
delta={`Δ${result.delta.h.toFixed(1)}°`}
pass={result.delta.h <= 4}
y={26}
/>
{/* S row */}
<HsbRow
label="S"
working={`${result.working.s}%`}
reference={`${result.reference.s}%`}
delta={`Δ${result.delta.s.toFixed(1)}%`}
pass={result.delta.s <= 3}
y={40}
/>
{/* B row */}
<HsbRow
label="B"
working={`${result.working.b}%`}
reference={`${result.reference.b}%`}
delta={`Δ${result.delta.b.toFixed(1)}%`}
pass={result.delta.b <= 3}
y={54}
/>
</g>
)}
</g>
</g>
);
}
function HsbRow({
label,
working,
reference,
delta,
pass,
y,
}: {
label: string;
working: string;
reference: string;
delta: string;
pass: boolean;
y: number;
}) {
return (
<g>
<text
x={8}
y={y}
fontSize="8"
fontWeight="700"
fontFamily="monospace"
fill="rgba(255,255,255,0.6)"
>
{label}:
</text>
<text
x={24}
y={y}
fontSize="8"
fontFamily="monospace"
fill="rgba(255,255,255,0.85)"
>
{working}
</text>
<text
x={56}
y={y}
fontSize="8"
fontFamily="monospace"
fill="rgba(255,255,255,0.45)"
>
</text>
<text
x={66}
y={y}
fontSize="8"
fontFamily="monospace"
fill="rgba(255,255,255,0.85)"
>
{reference}
</text>
<text
x={102}
y={y}
fontSize="8"
fontWeight="600"
fontFamily="monospace"
fill={pass ? "#22C55E" : "#EF4444"}
>
{delta}
</text>
<text
x={138}
y={y}
fontSize="8"
fontFamily="monospace"
fill={pass ? "#22C55E" : "#EF4444"}
>
{pass ? "✓" : "✗"}
</text>
</g>
);
}
/**
* Ghost marker shown on the reference image for an offset probe.
*/
export function ColorProbeGhostMarker({
index,
x,
y,
onDragStart,
}: {
index: number;
x: number;
y: number;
onDragStart?: (e: React.MouseEvent) => void;
}) {
return (
<g
transform={`translate(${x}, ${y})`}
style={{ cursor: "grab" }}
pointerEvents="auto"
onMouseDown={onDragStart}
>
<circle
r={MARKER_RADIUS}
fill="none"
stroke="rgba(255,255,255,0.5)"
strokeWidth={1.5}
strokeDasharray="3 2"
/>
<text
textAnchor="middle"
dominantBaseline="central"
fontSize="8"
fontWeight="600"
fontFamily="monospace"
fill="rgba(255,255,255,0.6)"
style={{ userSelect: "none" }}
>
{index}
</text>
</g>
);
}

View file

@ -0,0 +1,175 @@
"use client";
import { Trash2, Pipette } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ProbeResult } from "@/lib/utils/color";
interface ColorProbePanelProps {
probeResults: Map<number, ProbeResult>;
probeCount: number;
onClearAll: () => void;
isClearing?: boolean;
}
const STATUS_COLORS = {
pass: "#22C55E",
warn: "#F97316",
fail: "#EF4444",
} as const;
export function ColorProbePanel({
probeResults,
probeCount,
onClearAll,
isClearing,
}: ColorProbePanelProps) {
if (probeCount === 0) return null;
const passCount = Array.from(probeResults.values()).filter(
(r) => r.status === "pass"
).length;
const failCount = Array.from(probeResults.values()).filter(
(r) => r.status === "fail"
).length;
const warnCount = Array.from(probeResults.values()).filter(
(r) => r.status === "warn"
).length;
return (
<div className="w-[200px] rounded-lg border bg-[var(--card)]/95 shadow-xl backdrop-blur-sm">
{/* Header */}
<div className="flex items-center justify-between border-b px-3 py-1.5">
<div className="flex items-center gap-1.5">
<Pipette className="h-3 w-3 text-[var(--muted-foreground)]" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
CMF Probes
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-red-400 hover:text-red-300"
onClick={onClearAll}
disabled={isClearing}
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Clear all probes
</TooltipContent>
</Tooltip>
</div>
{/* Summary stats */}
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className="text-[10px] text-[var(--muted-foreground)]">
{probeCount} of 12
</span>
<div className="ml-auto flex items-center gap-1.5">
{passCount > 0 && (
<span
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
style={{ backgroundColor: "rgba(34,197,94,0.15)", color: STATUS_COLORS.pass }}
>
{passCount}
</span>
)}
{warnCount > 0 && (
<span
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
style={{ backgroundColor: "rgba(249,115,22,0.15)", color: STATUS_COLORS.warn }}
>
{warnCount}
</span>
)}
{failCount > 0 && (
<span
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
style={{ backgroundColor: "rgba(239,68,68,0.15)", color: STATUS_COLORS.fail }}
>
{failCount}
</span>
)}
</div>
</div>
{/* Probe rows */}
<div className="max-h-[240px] overflow-y-auto">
{Array.from({ length: probeCount }, (_, i) => i + 1).map((idx) => {
const result = probeResults.get(idx);
if (!result) return null;
const status = result.status;
const color = STATUS_COLORS[status];
return (
<div
key={idx}
className="flex items-center gap-2 border-b border-[var(--border)]/30 px-3 py-1 last:border-b-0"
>
{/* Index */}
<span className="w-3 text-right font-mono text-[9px] font-bold text-[var(--muted-foreground)]">
{idx}
</span>
{/* Status dot */}
<div
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
{/* Deltas */}
<div className="flex flex-1 items-center gap-1 font-mono text-[9px]">
<DeltaValue
label="H"
value={result.delta.h}
unit="°"
pass={result.delta.h <= 4}
/>
<DeltaValue
label="S"
value={result.delta.s}
unit="%"
pass={result.delta.s <= 3}
/>
<DeltaValue
label="B"
value={result.delta.b}
unit="%"
pass={result.delta.b <= 3}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
function DeltaValue({
label,
value,
unit,
pass,
}: {
label: string;
value: number;
unit: string;
pass: boolean;
}) {
return (
<span style={{ color: pass ? "rgba(255,255,255,0.5)" : "#EF4444" }}>
<span className="opacity-60">{label}</span>
{value.toFixed(1)}
{unit}
</span>
);
}

View file

@ -40,7 +40,7 @@ interface UndoEntry {
input?: CreateAnnotationInput;
}
const TOOL_TO_TYPE: Record<Exclude<AnnotationTool, "select">, AnnotationTypeValue> = {
const TOOL_TO_TYPE: Record<Exclude<AnnotationTool, "select" | "eyedropper">, AnnotationTypeValue> = {
rectangle: "RECTANGLE",
ellipse: "ELLIPSE",
arrow: "ARROW",
@ -230,7 +230,7 @@ export function useAnnotationState(
// Mouse handlers (need zoom/pan passed in)
const handleMouseDown = useCallback(
(e: React.MouseEvent, panX: number, panY: number, zoom: number) => {
if (activeTool === "select") {
if (activeTool === "select" || activeTool === "eyedropper") {
setSelectedId(null);
return;
}
@ -553,6 +553,7 @@ export function useAnnotationState(
case "f": setActiveTool("freehand"); return;
case "t": setActiveTool("text"); return;
case "p": setActiveTool("pin"); return;
case "d": setActiveTool("eyedropper"); return;
case "delete":
case "backspace":
if (selectedId) { e.preventDefault(); handleDeleteSelection(); }

View file

@ -0,0 +1,100 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Request failed: ${res.status}`);
}
return res.json();
}
export interface ColorProbe {
id: string;
revisionId: string;
index: number;
workingX: number;
workingY: number;
referenceX: number;
referenceY: number;
createdById: string;
createdBy: { id: string; name: string | null };
createdAt: string;
updatedAt: string;
}
export function useColorProbes(revisionId: string | null) {
return useQuery<ColorProbe[]>({
queryKey: ["color-probes", revisionId],
queryFn: () => fetchJson(`/api/revisions/${revisionId}/color-probes`),
enabled: !!revisionId,
});
}
export function useCreateColorProbe(revisionId: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateColorProbeInput) =>
fetchJson<ColorProbe>(`/api/revisions/${revisionId}/color-probes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
},
});
}
export function useUpdateColorProbe(revisionId: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
probeId,
data,
}: {
probeId: string;
data: UpdateColorProbeInput;
}) =>
fetchJson<ColorProbe>(
`/api/revisions/${revisionId}/color-probes/${probeId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
},
});
}
export function useDeleteColorProbe(revisionId: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: (probeId: string) =>
fetchJson(`/api/revisions/${revisionId}/color-probes/${probeId}`, {
method: "DELETE",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
},
});
}
export function useClearColorProbes(revisionId: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
fetchJson(`/api/revisions/${revisionId}/color-probes`, {
method: "DELETE",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
},
});
}

View file

@ -0,0 +1,105 @@
import { prisma } from "@/lib/prisma";
import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe";
/**
* List all color probes for a revision, ordered by index.
*/
export async function listColorProbes(revisionId: string) {
return prisma.colorProbe.findMany({
where: { revisionId },
orderBy: { index: "asc" },
include: { createdBy: { select: { id: true, name: true } } },
});
}
/**
* Create a new color probe on a revision.
*/
export async function createColorProbe(
revisionId: string,
userId: string,
input: CreateColorProbeInput
) {
return prisma.colorProbe.create({
data: {
revisionId,
createdById: userId,
index: input.index,
workingX: input.workingX,
workingY: input.workingY,
referenceX: input.referenceX,
referenceY: input.referenceY,
},
include: { createdBy: { select: { id: true, name: true } } },
});
}
/**
* Update a color probe's position.
*/
export async function updateColorProbe(
probeId: string,
input: UpdateColorProbeInput
) {
return prisma.colorProbe.update({
where: { id: probeId },
data: {
...(input.workingX !== undefined && { workingX: input.workingX }),
...(input.workingY !== undefined && { workingY: input.workingY }),
...(input.referenceX !== undefined && { referenceX: input.referenceX }),
...(input.referenceY !== undefined && { referenceY: input.referenceY }),
},
include: { createdBy: { select: { id: true, name: true } } },
});
}
/**
* Delete a single color probe.
*/
export async function deleteColorProbe(probeId: string) {
await prisma.colorProbe.delete({ where: { id: probeId } });
return { ok: true };
}
/**
* Delete all color probes for a revision (wipe).
*/
export async function clearColorProbes(revisionId: string) {
await prisma.colorProbe.deleteMany({ where: { revisionId } });
return { ok: true };
}
/**
* Copy probes from one revision to another (for R1 R2 carry-over).
* Keeps the same coordinates and indices.
*/
export async function copyProbes(
fromRevisionId: string,
toRevisionId: string,
userId: string
) {
const existing = await prisma.colorProbe.findMany({
where: { revisionId: fromRevisionId },
orderBy: { index: "asc" },
});
if (existing.length === 0) return [];
const created = await prisma.$transaction(
existing.map((probe) =>
prisma.colorProbe.create({
data: {
revisionId: toRevisionId,
createdById: userId,
index: probe.index,
workingX: probe.workingX,
workingY: probe.workingY,
referenceX: probe.referenceX,
referenceY: probe.referenceY,
},
})
)
);
return created;
}

View file

@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma";
import type { CreateRevisionInput, UpdateRevisionInput } from "@/lib/validators/revision";
import type { RevisionStatus } from "@/generated/prisma/client";
import { copyProbes } from "@/lib/services/color-probe-service";
/**
* Create a new revision round for a stage.
@ -8,7 +9,8 @@ import type { RevisionStatus } from "@/generated/prisma/client";
*/
export async function createRevision(
stageId: string,
data: CreateRevisionInput
data: CreateRevisionInput,
userId?: string
) {
const lastRevision = await prisma.revision.findFirst({
where: { deliverableStageId: stageId },
@ -17,7 +19,7 @@ export async function createRevision(
const roundNumber = (lastRevision?.roundNumber ?? 0) + 1;
return prisma.revision.create({
const revision = await prisma.revision.create({
data: {
deliverableStageId: stageId,
roundNumber,
@ -27,6 +29,17 @@ export async function createRevision(
attachments: data.attachments ?? undefined,
},
});
// Copy color probes from previous revision (non-blocking)
if (lastRevision && userId) {
try {
await copyProbes(lastRevision.id, revision.id, userId);
} catch {
// Non-critical: probes are a convenience, not a requirement
}
}
return revision;
}
/**

209
src/lib/utils/color.ts Normal file
View file

@ -0,0 +1,209 @@
/**
* Color utilities for CMF eyedropper probes.
* RGB HSB conversion, 5×5 pixel sampling, and delta calculation.
*/
export interface HSB {
h: number; // 0-360 degrees
s: number; // 0-100 percent
b: number; // 0-100 percent
}
export interface RGB {
r: number; // 0-255
g: number; // 0-255
b: number; // 0-255
}
export interface ProbeResult {
working: HSB;
reference: HSB;
delta: { h: number; s: number; b: number };
status: "pass" | "warn" | "fail";
}
// CMF tolerances
const TOLERANCE = { h: 4, s: 3, b: 3 };
// Warn when within 1.5× tolerance
const WARN_MULTIPLIER = 1.5;
/**
* Convert RGB to HSB (same as Photoshop's HSB).
*/
export function rgbToHsb(rgb: RGB): HSB {
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
// Brightness
const brightness = max * 100;
// Saturation
const saturation = max === 0 ? 0 : (delta / max) * 100;
// Hue
let hue = 0;
if (delta !== 0) {
if (max === r) {
hue = ((g - b) / delta) % 6;
} else if (max === g) {
hue = (b - r) / delta + 2;
} else {
hue = (r - g) / delta + 4;
}
hue *= 60;
if (hue < 0) hue += 360;
}
return {
h: Math.round(hue * 10) / 10,
s: Math.round(saturation * 10) / 10,
b: Math.round(brightness * 10) / 10,
};
}
/**
* Calculate hue delta accounting for circular wraparound.
* e.g., 358° vs 2° = Δ4°, not Δ356°.
*/
export function hueDelta(h1: number, h2: number): number {
const diff = Math.abs(h1 - h2);
return Math.min(diff, 360 - diff);
}
/**
* Compare two HSB values and return status.
*/
export function compareHsb(working: HSB, reference: HSB): ProbeResult {
const delta = {
h: hueDelta(working.h, reference.h),
s: Math.abs(working.s - reference.s),
b: Math.abs(working.b - reference.b),
};
const hFail = delta.h > TOLERANCE.h;
const sFail = delta.s > TOLERANCE.s;
const bFail = delta.b > TOLERANCE.b;
const hWarn = delta.h > TOLERANCE.h / WARN_MULTIPLIER;
const sWarn = delta.s > TOLERANCE.s / WARN_MULTIPLIER;
const bWarn = delta.b > TOLERANCE.b / WARN_MULTIPLIER;
let status: ProbeResult["status"] = "pass";
if (hFail || sFail || bFail) {
status = "fail";
} else if (hWarn || sWarn || bWarn) {
status = "warn";
}
return { working, reference, delta, status };
}
/**
* Sample a 5×5 pixel area from a canvas, averaging RGB values.
* Coordinates are in image space.
*/
export function samplePixels(
canvas: HTMLCanvasElement,
imageX: number,
imageY: number,
zoom: number,
panX: number,
panY: number
): RGB | null {
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const dpr = window.devicePixelRatio || 1;
// Sample 5×5 grid centered on the point
let totalR = 0;
let totalG = 0;
let totalB = 0;
let count = 0;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const imgPx = imageX + dx;
const imgPy = imageY + dy;
// Convert image coords to canvas pixel coords
const screenX = (panX + imgPx * zoom) * dpr;
const screenY = (panY + imgPy * zoom) * dpr;
// Bounds check
if (screenX < 0 || screenY < 0 || screenX >= canvas.width || screenY >= canvas.height) {
continue;
}
const pixel = ctx.getImageData(Math.round(screenX), Math.round(screenY), 1, 1).data;
totalR += pixel[0];
totalG += pixel[1];
totalB += pixel[2];
count++;
}
}
if (count === 0) return null;
return {
r: Math.round(totalR / count),
g: Math.round(totalG / count),
b: Math.round(totalB / count),
};
}
/**
* Sample pixels from an image URL by drawing to an offscreen canvas.
* Used when we don't have a visible canvas (e.g., reference image in comparison modes).
*/
export function sampleFromImage(
image: HTMLImageElement,
imageX: number,
imageY: number
): RGB | null {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
// Draw white background then image (matches main viewer behavior)
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0);
let totalR = 0;
let totalG = 0;
let totalB = 0;
let count = 0;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const px = Math.round(imageX + dx);
const py = Math.round(imageY + dy);
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) {
continue;
}
const pixel = ctx.getImageData(px, py, 1, 1).data;
totalR += pixel[0];
totalG += pixel[1];
totalB += pixel[2];
count++;
}
}
if (count === 0) return null;
return {
r: Math.round(totalR / count),
g: Math.round(totalG / count),
b: Math.round(totalB / count),
};
}

View file

@ -0,0 +1,20 @@
import { z } from "zod/v4";
export const createColorProbeSchema = z.object({
index: z.number().int().min(1).max(12),
workingX: z.number(),
workingY: z.number(),
referenceX: z.number(),
referenceY: z.number(),
});
export type CreateColorProbeInput = z.infer<typeof createColorProbeSchema>;
export const updateColorProbeSchema = z.object({
workingX: z.number().optional(),
workingY: z.number().optional(),
referenceX: z.number().optional(),
referenceY: z.number().optional(),
});
export type UpdateColorProbeInput = z.infer<typeof updateColorProbeSchema>;