From 081866b7d3eeca89156ce091e3013b0b8c94773c Mon Sep 17 00:00:00 2001 From: Jun Yang Ang Date: Sat, 7 Jun 2025 15:22:38 -0700 Subject: [PATCH 1/3] laggy --- .../tabs/PanoramasTab/PanoramaGrid.tsx | 326 +++++++++++++++++- 1 file changed, 318 insertions(+), 8 deletions(-) diff --git a/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx b/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx index 2a4a068..c0cc86b 100644 --- a/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx +++ b/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx @@ -1,3 +1,4 @@ +import { useState, useRef, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -6,6 +7,13 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; import { Upload, MoreVertical, @@ -15,8 +23,11 @@ import { List, Box, Loader2, + Wand2, } from "lucide-react"; +import { createClient } from "@/utils/supabase/client"; import { Panorama } from "../../../../../../hooks/usePanoramas"; +import BlurWorkerURL from "@/workers/blurWorker.ts?worker"; // vite / next 14+ interface PanoramaGridProps { panoramas: Panorama[]; @@ -53,18 +64,234 @@ export default function PanoramaGrid({ setGenerate360DialogOpen, getProjectPanoramas, }: PanoramaGridProps) { - // Determine which panoramas to show based on currentFolder + /* ------------------------------------------------------------------ */ + /* ----------------------- BLUR & REDACT --------------------------- */ + /* ------------------------------------------------------------------ */ + const supabase = createClient(); + const [redactDialogOpen, setRedactDialogOpen] = useState(false); + const [redactTarget, setRedactTarget] = useState(null); + + const canvasRef = useRef(null); // visible canvas + const [imgEl, setImgEl] = useState(null); + + const [scale, setScale] = useState(1); // display-scale factor + const [rects, setRects] = useState< + { x: number; y: number; w: number; h: number }[] + >([]); + + const [drawing, setDrawing] = useState(false); + const startPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + + const [saving, setSaving] = useState(false); + const [blurredUrls, setBlurredUrls] = useState>({}); + const panoramasToShow = getProjectPanoramas(); + /* ------------------ CANVAS DRAW HELPERS ------------------ */ + const blurRect = ( + ctx: CanvasRenderingContext2D, + img: HTMLImageElement, + r: { x: number; y: number; w: number; h: number }, + scaleFactor = 1 + ) => { + ctx.save(); + ctx.beginPath(); + ctx.rect(r.x, r.y, r.w, r.h); + ctx.clip(); + ctx.filter = "blur(10px)"; + ctx.drawImage( + img, + r.x / scaleFactor, + r.y / scaleFactor, + r.w / scaleFactor, + r.h / scaleFactor, + r.x, + r.y, + r.w, + r.h + ); + ctx.restore(); + + // outline + ctx.save(); + ctx.strokeStyle = "white"; + ctx.setLineDash([6, 4]); + ctx.lineWidth = 2; + ctx.strokeRect(r.x, r.y, r.w, r.h); + ctx.restore(); + }; + + const redraw = useCallback( + (preview?: { x: number; y: number; w: number; h: number } | null) => { + if (!canvasRef.current || !imgEl) return; + const ctx = canvasRef.current.getContext("2d")!; + ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); + ctx.drawImage( + imgEl, + 0, + 0, + canvasRef.current.width, + canvasRef.current.height + ); + + rects.forEach((r) => blurRect(ctx, imgEl, r, scale)); + if (preview) blurRect(ctx, imgEl, preview, scale); + }, + [imgEl, rects, scale] + ); + + /* ---------------- DIALOG OPEN & IMAGE LOAD ---------------- */ + const openRedactor = (p: Panorama) => { + setRects([]); + setRedactTarget(p); + setRedactDialogOpen(true); + }; + + useEffect(() => { + if (!redactDialogOpen || !redactTarget) return; + let objectURL = ""; + + const fetchAndLoad = async () => { + const res = await fetch(redactTarget.url ?? "", { mode: "cors" }); + const blob = await res.blob(); + objectURL = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + const maxDisplay = 800; + const sc = img.width > maxDisplay ? maxDisplay / img.width : 1; + setScale(sc); + + if (canvasRef.current) { + canvasRef.current.width = img.width * sc; + canvasRef.current.height = img.height * sc; + } + + setImgEl(img); + redraw(); + }; + img.src = objectURL; + }; + + fetchAndLoad(); + return () => { + if (objectURL) URL.revokeObjectURL(objectURL); + }; + }, [redactDialogOpen, redactTarget, redraw]); + + /* ------------------- MOUSE HANDLERS ---------------------- */ + const handleMouseDown = (e: React.MouseEvent) => { + if (!canvasRef.current) return; + const rect = canvasRef.current.getBoundingClientRect(); + startPos.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + setDrawing(true); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!drawing || !canvasRef.current) return; + const rectCanvas = canvasRef.current.getBoundingClientRect(); + const curr = { + x: e.clientX - rectCanvas.left, + y: e.clientY - rectCanvas.top, + }; + const preview = { + x: Math.min(startPos.current.x, curr.x), + y: Math.min(startPos.current.y, curr.y), + w: Math.abs(curr.x - startPos.current.x), + h: Math.abs(curr.y - startPos.current.y), + }; + redraw(preview); + }; + + const handleMouseUp = () => { + if (!drawing) return; + setDrawing(false); + setRects((prev) => [...prev, prevPreview.current!]); + redraw(); + }; + + // keep track of current preview rectangle + const prevPreview = useRef<{ + x: number; + y: number; + w: number; + h: number; + } | null>(null); + useEffect(() => { + if (!drawing) prevPreview.current = null; + }, [drawing]); + + /* ------------------- SAVE BLURRED IMAGE ------------------ */ + const handleSave = async () => { + if (!redactTarget) return; + setSaving(true); + + try { + // fetch original JPEG once (as Blob) so CORS isn’t an issue + const originalBlob = await (await fetch(redactTarget.url!)).blob(); + + // spin up worker + const worker = new Worker(new URL(BlurWorkerURL, import.meta.url)); + worker.postMessage({ + imageBlob: originalBlob, + rects: rects.map((r) => ({ + x: Math.round(r.x / scale), + y: Math.round(r.y / scale), + w: Math.round(r.w / scale), + h: Math.round(r.h / scale), + })), + }); + + worker.onmessage = async (ev) => { + const { blob } = ev.data as { blob: Blob }; + + const bucket = redactTarget.is_public + ? "panoramas-public" + : "panoramas-private"; + + await supabase.storage + .from(bucket) + .upload(redactTarget.storage_path, blob, { + upsert: true, + contentType: "image/jpeg", + }); + + // cache-bust & refresh UI + const ts = Date.now(); + const newUrl = redactTarget.is_public + ? supabase.storage + .from(bucket) + .getPublicUrl(redactTarget.storage_path).data.publicUrl + + `?t=${ts}` + : ( + await supabase.storage + .from(bucket) + .createSignedUrl(redactTarget.storage_path, 3600) + ).data!.signedUrl + `&t=${ts}`; + + setBlurredUrls((prev) => ({ ...prev, [redactTarget.id]: newUrl })); + setRedactDialogOpen(false); + setSaving(false); + worker.terminate(); + }; + } catch (err) { + console.error(err); + alert("Failed to save blurred image"); + setSaving(false); + } + }; + + /* ------------------------------------------------------------------ */ + /* ------------------- MAIN COMPONENT UI --------------------------- */ + /* ------------------------------------------------------------------ */ return (
+ {/* ---------- main card ---------- */}
- - {"All 360° Images"} - + {"All 360° Images"}
+ {/* view toggle */} - {/* Panorama upload button */} + {/* upload */}
+ + {/* ---------- main content ---------- */} {uploading || processing ? (
@@ -152,7 +381,12 @@ export default function PanoramaGrid({
{panorama.name} @@ -166,6 +400,7 @@ export default function PanoramaGrid({ {panorama.name} {panorama.is_processing && " (Processing)"}
+ {/* ---- dropdown ---- */}
Rename + + {/* NEW OPTION */} + { + e.stopPropagation(); + openRedactor(panorama); + }} + disabled={panorama.is_processing} + > + + Blur & Redact + + { @@ -212,6 +460,7 @@ export default function PanoramaGrid({ ))}
) : ( + // ---------- list view ----------
{panoramasToShow.map((panorama) => (
{panorama.name} @@ -262,6 +516,19 @@ export default function PanoramaGrid({ Rename + + {/* NEW OPTION (list view) */} + { + e.stopPropagation(); + openRedactor(panorama); + }} + disabled={panorama.is_processing} + > + + Blur & Redact + + { @@ -281,6 +548,7 @@ export default function PanoramaGrid({
)} + {/* empty-state */} {panoramasToShow.length === 0 && (
@@ -315,6 +583,48 @@ export default function PanoramaGrid({ )} + + {/* ----------------- REDACTION DIALOG ----------------- */} + + + + Blur & Redact + + +
+ +
+ + + + + +
+
); } From 0c849131af9b555ebab8ba5331b07e95d7d9ec8c Mon Sep 17 00:00:00 2001 From: Jun Yang Ang Date: Sat, 7 Jun 2025 15:22:52 -0700 Subject: [PATCH 2/3] still laggy --- .../tabs/PanoramasTab/PanoramaGrid.tsx | 115 ++++++++++-------- 1 file changed, 61 insertions(+), 54 deletions(-) diff --git a/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx b/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx index c0cc86b..587838a 100644 --- a/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx +++ b/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx @@ -27,7 +27,6 @@ import { } from "lucide-react"; import { createClient } from "@/utils/supabase/client"; import { Panorama } from "../../../../../../hooks/usePanoramas"; -import BlurWorkerURL from "@/workers/blurWorker.ts?worker"; // vite / next 14+ interface PanoramaGridProps { panoramas: Panorama[]; @@ -222,62 +221,70 @@ export default function PanoramaGrid({ /* ------------------- SAVE BLURRED IMAGE ------------------ */ const handleSave = async () => { - if (!redactTarget) return; + if (!imgEl || !redactTarget) return; setSaving(true); - try { - // fetch original JPEG once (as Blob) so CORS isn’t an issue - const originalBlob = await (await fetch(redactTarget.url!)).blob(); - - // spin up worker - const worker = new Worker(new URL(BlurWorkerURL, import.meta.url)); - worker.postMessage({ - imageBlob: originalBlob, - rects: rects.map((r) => ({ - x: Math.round(r.x / scale), - y: Math.round(r.y / scale), - w: Math.round(r.w / scale), - h: Math.round(r.h / scale), - })), - }); - - worker.onmessage = async (ev) => { - const { blob } = ev.data as { blob: Blob }; - - const bucket = redactTarget.is_public - ? "panoramas-public" - : "panoramas-private"; - - await supabase.storage - .from(bucket) - .upload(redactTarget.storage_path, blob, { - upsert: true, - contentType: "image/jpeg", - }); - - // cache-bust & refresh UI - const ts = Date.now(); - const newUrl = redactTarget.is_public - ? supabase.storage + const out = document.createElement("canvas"); + out.width = imgEl.width; + out.height = imgEl.height; + const octx = out.getContext("2d")!; + octx.drawImage(imgEl, 0, 0); + + rects.forEach((r) => + blurRect( + octx, + imgEl, + { x: r.x / scale, y: r.y / scale, w: r.w / scale, h: r.h / scale }, + 1 + ) + ); + + out.toBlob( + async (blob) => { + if (!blob) { + setSaving(false); + alert("Failed to create image blob."); + return; + } + try { + const bucket = redactTarget.is_public + ? "panoramas-public" + : "panoramas-private"; + await supabase.storage + .from(bucket) + .upload(redactTarget.storage_path, blob, { + upsert: true, + contentType: "image/jpeg", + }); + + /* bust cache & refresh thumbnail */ + const ts = Date.now(); + let newUrl: string | null = null; + if (redactTarget.is_public) { + const { data } = supabase.storage .from(bucket) - .getPublicUrl(redactTarget.storage_path).data.publicUrl + - `?t=${ts}` - : ( - await supabase.storage - .from(bucket) - .createSignedUrl(redactTarget.storage_path, 3600) - ).data!.signedUrl + `&t=${ts}`; - - setBlurredUrls((prev) => ({ ...prev, [redactTarget.id]: newUrl })); - setRedactDialogOpen(false); - setSaving(false); - worker.terminate(); - }; - } catch (err) { - console.error(err); - alert("Failed to save blurred image"); - setSaving(false); - } + .getPublicUrl(redactTarget.storage_path); + newUrl = data.publicUrl + `?t=${ts}`; + } else { + const { data } = await supabase.storage + .from(bucket) + .createSignedUrl(redactTarget.storage_path, 3600); + newUrl = (data?.signedUrl ?? "") + `&t=${ts}`; + } + if (newUrl) { + setBlurredUrls((prev) => ({ ...prev, [redactTarget.id]: newUrl })); + } + setRedactDialogOpen(false); + } catch (err) { + console.error(err); + alert("Failed to save blurred image"); + } finally { + setSaving(false); + } + }, + "image/jpeg", + 0.95 + ); }; /* ------------------------------------------------------------------ */ From cae0a54f068b1d5f1c2d317ae8c08d9342a66346 Mon Sep 17 00:00:00 2001 From: Jun Yang Ang Date: Sat, 7 Jun 2025 15:30:06 -0700 Subject: [PATCH 3/3] as good as it gets --- .../tabs/PanoramasTab/PanoramaGrid.tsx | 208 +++++++----------- 1 file changed, 84 insertions(+), 124 deletions(-) diff --git a/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx b/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx index 587838a..0cb8de4 100644 --- a/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx +++ b/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx @@ -63,67 +63,29 @@ export default function PanoramaGrid({ setGenerate360DialogOpen, getProjectPanoramas, }: PanoramaGridProps) { - /* ------------------------------------------------------------------ */ - /* ----------------------- BLUR & REDACT --------------------------- */ - /* ------------------------------------------------------------------ */ + // -------- local state for redaction -------- const supabase = createClient(); const [redactDialogOpen, setRedactDialogOpen] = useState(false); const [redactTarget, setRedactTarget] = useState(null); - - const canvasRef = useRef(null); // visible canvas + const canvasRef = useRef(null); const [imgEl, setImgEl] = useState(null); - - const [scale, setScale] = useState(1); // display-scale factor const [rects, setRects] = useState< { x: number; y: number; w: number; h: number }[] >([]); - const [drawing, setDrawing] = useState(false); const startPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - + const [scale, setScale] = useState(1); const [saving, setSaving] = useState(false); const [blurredUrls, setBlurredUrls] = useState>({}); const panoramasToShow = getProjectPanoramas(); - /* ------------------ CANVAS DRAW HELPERS ------------------ */ - const blurRect = ( - ctx: CanvasRenderingContext2D, - img: HTMLImageElement, - r: { x: number; y: number; w: number; h: number }, - scaleFactor = 1 - ) => { - ctx.save(); - ctx.beginPath(); - ctx.rect(r.x, r.y, r.w, r.h); - ctx.clip(); - ctx.filter = "blur(10px)"; - ctx.drawImage( - img, - r.x / scaleFactor, - r.y / scaleFactor, - r.w / scaleFactor, - r.h / scaleFactor, - r.x, - r.y, - r.w, - r.h - ); - ctx.restore(); - - // outline - ctx.save(); - ctx.strokeStyle = "white"; - ctx.setLineDash([6, 4]); - ctx.lineWidth = 2; - ctx.strokeRect(r.x, r.y, r.w, r.h); - ctx.restore(); - }; - + // -------- helper to draw (or re-draw) canvas -------- const redraw = useCallback( - (preview?: { x: number; y: number; w: number; h: number } | null) => { + (previewRect?: { x: number; y: number; w: number; h: number } | null) => { if (!canvasRef.current || !imgEl) return; - const ctx = canvasRef.current.getContext("2d")!; + const ctx = canvasRef.current.getContext("2d"); + if (!ctx) return; ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); ctx.drawImage( imgEl, @@ -133,51 +95,64 @@ export default function PanoramaGrid({ canvasRef.current.height ); - rects.forEach((r) => blurRect(ctx, imgEl, r, scale)); - if (preview) blurRect(ctx, imgEl, preview, scale); + const allRects = previewRect ? [...rects, previewRect] : rects; + + allRects.forEach((r) => { + ctx.save(); + ctx.filter = "blur(10px)"; + // blur only the sub-rectangle + ctx.drawImage( + imgEl, + r.x / scale, + r.y / scale, + r.w / scale, + r.h / scale, + r.x, + r.y, + r.w, + r.h + ); + ctx.restore(); + // draw dashed outline so user sees selection + ctx.save(); + ctx.strokeStyle = "rgba(255,255,255,0.8)"; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.strokeRect(r.x, r.y, r.w, r.h); + ctx.restore(); + }); }, [imgEl, rects, scale] ); - /* ---------------- DIALOG OPEN & IMAGE LOAD ---------------- */ + // -------- open redaction dialog -------- const openRedactor = (p: Panorama) => { - setRects([]); setRedactTarget(p); + setRects([]); setRedactDialogOpen(true); }; + // -------- load image when dialog opens -------- useEffect(() => { if (!redactDialogOpen || !redactTarget) return; - let objectURL = ""; - - const fetchAndLoad = async () => { - const res = await fetch(redactTarget.url ?? "", { mode: "cors" }); - const blob = await res.blob(); - objectURL = URL.createObjectURL(blob); - const img = new Image(); - img.onload = () => { - const maxDisplay = 800; - const sc = img.width > maxDisplay ? maxDisplay / img.width : 1; - setScale(sc); - - if (canvasRef.current) { - canvasRef.current.width = img.width * sc; - canvasRef.current.height = img.height * sc; - } - - setImgEl(img); - redraw(); - }; - img.src = objectURL; - }; - - fetchAndLoad(); - return () => { - if (objectURL) URL.revokeObjectURL(objectURL); + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = redactTarget.url ?? ""; + img.onload = () => { + // fit image into at most 800px width for the dialog + const maxW = 800; + const sc = img.width > maxW ? maxW / img.width : 1; + setScale(sc); + if (canvasRef.current) { + canvasRef.current.width = img.width * sc; + canvasRef.current.height = img.height * sc; + } + setImgEl(img); + redraw(); }; }, [redactDialogOpen, redactTarget, redraw]); - /* ------------------- MOUSE HANDLERS ---------------------- */ + // -------- mouse handlers -------- const handleMouseDown = (e: React.MouseEvent) => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); @@ -201,49 +176,37 @@ export default function PanoramaGrid({ redraw(preview); }; - const handleMouseUp = () => { - if (!drawing) return; + const handleMouseUp = (e: React.MouseEvent) => { + if (!drawing || !canvasRef.current) return; + const rectCanvas = canvasRef.current.getBoundingClientRect(); + const end = { + x: e.clientX - rectCanvas.left, + y: e.clientY - rectCanvas.top, + }; + const newRect = { + x: Math.min(startPos.current.x, end.x), + y: Math.min(startPos.current.y, end.y), + w: Math.abs(end.x - startPos.current.x), + h: Math.abs(end.y - startPos.current.y), + }; + setRects((prev) => [...prev, newRect]); setDrawing(false); - setRects((prev) => [...prev, prevPreview.current!]); - redraw(); }; - // keep track of current preview rectangle - const prevPreview = useRef<{ - x: number; - y: number; - w: number; - h: number; - } | null>(null); + // re-draw whenever rects change useEffect(() => { - if (!drawing) prevPreview.current = null; - }, [drawing]); + if (rects.length > 0) redraw(); + }, [rects, redraw]); - /* ------------------- SAVE BLURRED IMAGE ------------------ */ + // -------- save blurred image -------- const handleSave = async () => { - if (!imgEl || !redactTarget) return; + if (!canvasRef.current || !redactTarget) return; setSaving(true); - - const out = document.createElement("canvas"); - out.width = imgEl.width; - out.height = imgEl.height; - const octx = out.getContext("2d")!; - octx.drawImage(imgEl, 0, 0); - - rects.forEach((r) => - blurRect( - octx, - imgEl, - { x: r.x / scale, y: r.y / scale, w: r.w / scale, h: r.h / scale }, - 1 - ) - ); - - out.toBlob( + canvasRef.current.toBlob( async (blob) => { if (!blob) { setSaving(false); - alert("Failed to create image blob."); + alert("Failed to generate image blob"); return; } try { @@ -257,26 +220,25 @@ export default function PanoramaGrid({ contentType: "image/jpeg", }); - /* bust cache & refresh thumbnail */ - const ts = Date.now(); + // refresh URL (signed or public) let newUrl: string | null = null; if (redactTarget.is_public) { const { data } = supabase.storage .from(bucket) .getPublicUrl(redactTarget.storage_path); - newUrl = data.publicUrl + `?t=${ts}`; + newUrl = data.publicUrl + `?t=${Date.now()}`; // cache-buster } else { const { data } = await supabase.storage .from(bucket) .createSignedUrl(redactTarget.storage_path, 3600); - newUrl = (data?.signedUrl ?? "") + `&t=${ts}`; + newUrl = (data?.signedUrl ?? "") + `&t=${Date.now()}`; } if (newUrl) { setBlurredUrls((prev) => ({ ...prev, [redactTarget.id]: newUrl })); } setRedactDialogOpen(false); } catch (err) { - console.error(err); + console.error("Save blurred image error:", err); alert("Failed to save blurred image"); } finally { setSaving(false); @@ -287,9 +249,9 @@ export default function PanoramaGrid({ ); }; - /* ------------------------------------------------------------------ */ - /* ------------------- MAIN COMPONENT UI --------------------------- */ - /* ------------------------------------------------------------------ */ + // -------------------------------------------------- + // ----------------------- UI ----------------------- + // -------------------------------------------------- return (
{/* ---------- main card ---------- */} @@ -445,7 +407,7 @@ export default function PanoramaGrid({ disabled={panorama.is_processing} > - Blur & Redact + Blur & Redact (beta) - Blur & Redact + Blur & Redact (beta) - {/* ----------------- REDACTION DIALOG ----------------- */} + {/* ---------- REDACTION DIALOG ---------- */} - Blur & Redact + Blur & Redact (beta) -
-