Markup bug fixes
This commit is contained in:
parent
e3332c5dc5
commit
9a10cd8063
5 changed files with 256 additions and 4 deletions
|
|
@ -743,6 +743,7 @@ model Annotation {
|
|||
|
||||
@@index([commentId])
|
||||
@@index([revisionId])
|
||||
@@index([revisionId, timestampSeconds])
|
||||
@@map("annotations")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ export function AnnotationLayer({
|
|||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => ann.commitTextAnnotation(), 150);
|
||||
setTimeout(() => ann.commitTextAnnotationOnBlur(), 150);
|
||||
}}
|
||||
className="h-7 min-w-[180px] border-[var(--primary)] bg-[var(--card)] text-sm"
|
||||
placeholder="Type label..."
|
||||
|
|
|
|||
|
|
@ -95,6 +95,38 @@ export function VideoAnnotationLayer({
|
|||
|
||||
const ann = useAnnotationState(revisionId, stageId, videoContext);
|
||||
|
||||
// Clipboard paste handler for screenshots
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (!revisionId || !stageId) return;
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
// For video annotations, use native coords directly (no pan/zoom transform)
|
||||
await ann.handleScreenshotPaste(
|
||||
file,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
0, // panX
|
||||
0, // panY
|
||||
videoWidth / coordWidth, // zoom = display/native ratio
|
||||
{ width: coordWidth, height: coordHeight }
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [revisionId, stageId, videoWidth, videoHeight, coordWidth, coordHeight, ann]);
|
||||
|
||||
// Auto-pause video when a drawing tool is activated
|
||||
useEffect(() => {
|
||||
if (ann.activeTool !== "move" && ann.activeTool !== "eyedropper" && isPlaying) {
|
||||
|
|
@ -311,7 +343,7 @@ export function VideoAnnotationLayer({
|
|||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => ann.commitTextAnnotation(), 150);
|
||||
setTimeout(() => ann.commitTextAnnotationOnBlur(), 150);
|
||||
}}
|
||||
className="h-7 min-w-[180px] border-[var(--primary)] bg-[var(--card)] text-sm"
|
||||
placeholder="Type label..."
|
||||
|
|
@ -320,6 +352,135 @@ export function VideoAnnotationLayer({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Screenshot callouts (HTML layer, same approach as image annotation layer) ── */}
|
||||
{ann.visible && ann.screenshotAnnotations.length > 0 && (
|
||||
<div
|
||||
className="absolute inset-0 z-25"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{ann.screenshotAnnotations
|
||||
.filter((a: any) => {
|
||||
if (a.timestampSeconds == null) return true;
|
||||
if (showAllAnnotations) return true;
|
||||
return Math.abs(a.timestampSeconds - currentTime) <= ANNOTATION_TIME_WINDOW;
|
||||
})
|
||||
.map((a: any) => {
|
||||
const d = a.data ?? {};
|
||||
const imgX = d.x ?? 0;
|
||||
const imgY = d.y ?? 0;
|
||||
const w = d.width ?? 200;
|
||||
const h = d.height ?? 150;
|
||||
// Convert native video coords → screen coords using the viewBox scale
|
||||
const scale = videoWidth / coordWidth;
|
||||
const screenLeft = imgX * scale;
|
||||
const screenTop = imgY * scale;
|
||||
const screenW = w * scale;
|
||||
const screenH = h * scale;
|
||||
const isSelected = a.id === ann.selectedId;
|
||||
const isNearTime = a.timestampSeconds != null
|
||||
? Math.abs(a.timestampSeconds - currentTime) <= ANNOTATION_TIME_WINDOW
|
||||
: true;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: screenLeft,
|
||||
top: screenTop,
|
||||
width: screenW,
|
||||
height: screenH,
|
||||
pointerEvents: "auto",
|
||||
cursor: "grab",
|
||||
border: `2px solid ${isSelected ? "#fff" : "rgba(0,0,0,0.6)"}`,
|
||||
boxShadow: isSelected
|
||||
? "0 0 0 1px rgba(255,255,255,0.8), 0 4px 12px rgba(0,0,0,0.5)"
|
||||
: "0 2px 8px rgba(0,0,0,0.4)",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
userSelect: "none",
|
||||
opacity: isNearTime ? 1 : 0.3,
|
||||
transition: "opacity 0.3s",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
ann.setSelectedId(a.id);
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origX = imgX;
|
||||
const origY = imgY;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - startX) * screenToNativeScale;
|
||||
const dy = (me.clientY - startY) * screenToNativeScale;
|
||||
ann.handleScreenshotMove(a.id, origX + dx, origY + dy);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={d.imageUrl ?? ""}
|
||||
alt="Screenshot"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{/* Resize handle */}
|
||||
{isSelected && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: "white",
|
||||
border: "1px solid rgba(0,0,0,0.3)",
|
||||
borderRadius: 2,
|
||||
cursor: "nwse-resize",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origW = w;
|
||||
const origH = h;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - startX) * screenToNativeScale;
|
||||
const dy = (me.clientY - startY) * screenToNativeScale;
|
||||
ann.handleScreenshotResize(
|
||||
a.id,
|
||||
Math.max(40, origW + dx),
|
||||
Math.max(40, origH + dy)
|
||||
);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Annotation comment popover */}
|
||||
{ann.pendingAnnotation && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ export function useAnnotationState(
|
|||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const textInputRef = useRef<HTMLInputElement>(null);
|
||||
const commentInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
/** Timestamp when the text input was created — used to ignore premature blur */
|
||||
const textInputCreatedAt = useRef<number>(0);
|
||||
|
||||
// Data hooks
|
||||
const { data: annotationsRaw } = useAnnotations(revisionId);
|
||||
|
|
@ -250,9 +252,11 @@ export function useAnnotationState(
|
|||
const img = getImageCoords(e, panX, panY, zoom);
|
||||
|
||||
if (activeTool === "text") {
|
||||
e.preventDefault(); // Prevent browser mousedown focus management from stealing focus from the input
|
||||
const svgEl = svgRef.current;
|
||||
if (!svgEl) return;
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
textInputCreatedAt.current = Date.now();
|
||||
setTextInput({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
|
|
@ -380,6 +384,17 @@ export function useAnnotationState(
|
|||
setTextValue("");
|
||||
}, [textInput, textValue, color, saveAnnotation]);
|
||||
|
||||
/** Blur-safe variant: ignores blur events that fire within 300ms of input creation
|
||||
* (caused by browser mousedown focus management stealing focus from the new input). */
|
||||
const commitTextAnnotationOnBlur = useCallback(() => {
|
||||
if (Date.now() - textInputCreatedAt.current < 300) {
|
||||
// Re-focus — the blur was caused by the browser, not the user
|
||||
setTimeout(() => textInputRef.current?.focus(), 0);
|
||||
return;
|
||||
}
|
||||
commitTextAnnotation();
|
||||
}, [commitTextAnnotation]);
|
||||
|
||||
// Undo / Redo
|
||||
const handleUndo = useCallback(() => {
|
||||
const last = undoStack[undoStack.length - 1];
|
||||
|
|
@ -540,8 +555,15 @@ export function useAnnotationState(
|
|||
);
|
||||
|
||||
toast.success("Screenshot pasted — add a comment");
|
||||
} catch {
|
||||
toast.error("Failed to paste screenshot");
|
||||
} catch (err) {
|
||||
console.error("[Screenshot paste]", err);
|
||||
const message =
|
||||
err instanceof TypeError
|
||||
? "Network error — check your connection"
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Unknown error";
|
||||
toast.error(`Failed to paste screenshot: ${message}`);
|
||||
}
|
||||
},
|
||||
[revisionId, stageId, color, queueAnnotation]
|
||||
|
|
@ -660,6 +682,7 @@ export function useAnnotationState(
|
|||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
commitTextAnnotation,
|
||||
commitTextAnnotationOnBlur,
|
||||
handleDeleteSelection,
|
||||
handleAnnotationMove,
|
||||
handleScreenshotMove,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import path from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { mkdir } from "fs/promises";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation";
|
||||
import { createFeedbackFromAnnotation } from "@/lib/services/feedback-service";
|
||||
import { extractThumbnail, isFFmpegAvailable } from "@/lib/services/video-service";
|
||||
|
||||
const VIDEO_UPLOADS_DIR =
|
||||
process.env.VIDEO_UPLOADS_DIR ||
|
||||
(process.env.NODE_ENV === "production"
|
||||
? "/data/uploads/revisions"
|
||||
: path.join(process.cwd(), "data", "uploads", "revisions"));
|
||||
|
||||
/**
|
||||
* List all annotations for a revision, including the linked comment + author.
|
||||
|
|
@ -78,9 +88,66 @@ export async function createAnnotation(
|
|||
// Non-critical: don't fail annotation creation if feedback creation fails
|
||||
}
|
||||
|
||||
// Async: extract frame thumbnail for video annotations (non-blocking)
|
||||
if (input.timestampSeconds != null) {
|
||||
extractAnnotationFrameThumbnail(result.id, revisionId, input.timestampSeconds).catch(
|
||||
(err) => console.error("[Annotation] Frame thumbnail extraction failed:", err)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a frame thumbnail for a video annotation and update the record.
|
||||
* Runs async — does not block annotation creation.
|
||||
*/
|
||||
async function extractAnnotationFrameThumbnail(
|
||||
annotationId: string,
|
||||
revisionId: string,
|
||||
timestampSeconds: number
|
||||
): Promise<void> {
|
||||
if (!(await isFFmpegAvailable())) return;
|
||||
|
||||
// Find the video file path from the revision's attachments
|
||||
const revision = await prisma.revision.findUnique({
|
||||
where: { id: revisionId },
|
||||
select: { attachments: true },
|
||||
});
|
||||
|
||||
const attachments = (revision?.attachments as Record<string, any>) ?? {};
|
||||
const videoUrl: string | undefined =
|
||||
attachments.video?.url ?? attachments.referenceVideo?.url;
|
||||
|
||||
if (!videoUrl) return;
|
||||
|
||||
// Resolve local file path from the API URL
|
||||
// URL format: /api/uploads/revisions/{revisionId}/{filename}
|
||||
const urlParts = videoUrl.split("/");
|
||||
const filename = urlParts[urlParts.length - 1];
|
||||
const videoPath = path.join(VIDEO_UPLOADS_DIR, revisionId, filename);
|
||||
|
||||
if (!existsSync(videoPath)) return;
|
||||
|
||||
// Extract frame thumbnail
|
||||
const thumbDir = path.join(VIDEO_UPLOADS_DIR, revisionId, "annotation_frames");
|
||||
if (!existsSync(thumbDir)) {
|
||||
await mkdir(thumbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const thumbFilename = `frame_${annotationId}.jpg`;
|
||||
const thumbPath = path.join(thumbDir, thumbFilename);
|
||||
const created = await extractThumbnail(videoPath, thumbPath, timestampSeconds);
|
||||
|
||||
if (created) {
|
||||
const thumbUrl = `/api/uploads/revisions/${revisionId}/annotation_frames/${thumbFilename}`;
|
||||
await prisma.annotation.update({
|
||||
where: { id: annotationId },
|
||||
data: { frameThumbnailUrl: thumbUrl },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update annotation data (position, shape data).
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue