| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 |
- 'use client';
- import { useRef, useEffect, useState, useCallback } from 'react';
- import { Button } from '@/components/ui/button';
- import { Card, CardContent } from '@/components/ui/card';
- import { Label } from '@/components/ui/label';
- import { Input } from '@/components/ui/input';
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
- import { Upload, Download, Eraser, Brush, RotateCcw, Link as LinkIcon, Loader2 } from 'lucide-react';
- import { fileToBase64 } from '@/lib/utils';
- import { validateImageUrlWithBase64 } from '@/lib/image-validation';
- import { apiClient } from '@/lib/api';
- interface InpaintingCanvasProps {
- onSourceImageChange: (image: string) => void;
- onMaskImageChange: (image: string) => void;
- className?: string;
- targetWidth?: number;
- targetHeight?: number;
- }
- export function InpaintingCanvas({
- onSourceImageChange,
- onMaskImageChange,
- className,
- targetWidth,
- targetHeight
- }: InpaintingCanvasProps) {
- const canvasRef = useRef<HTMLCanvasElement>(null);
- const maskCanvasRef = useRef<HTMLCanvasElement>(null); // Keep for mask generation
- const fileInputRef = useRef<HTMLInputElement>(null);
- const [sourceImage, setSourceImage] = useState<string | null>(null);
- const [originalSourceImage, setOriginalSourceImage] = useState<string | null>(null);
- const [isDrawing, setIsDrawing] = useState(false);
- const [brushSize, setBrushSize] = useState(20);
- const [isEraser, setIsEraser] = useState(false);
- const [canvasSize, setCanvasSize] = useState({ width: 512, height: 512 });
- const [inputMode, setInputMode] = useState<'file' | 'url'>('file');
- const [urlInput, setUrlInput] = useState('');
- const [isLoadingUrl, setIsLoadingUrl] = useState(false);
- const [urlError, setUrlError] = useState<string | null>(null);
- const [isResizing, setIsResizing] = useState(false);
- // Initialize canvases
- useEffect(() => {
- const canvas = canvasRef.current;
- const maskCanvas = maskCanvasRef.current;
- if (!canvas || !maskCanvas) return;
- const ctx = canvas.getContext('2d');
- const maskCtx = maskCanvas.getContext('2d');
- if (!ctx || !maskCtx) return;
- // Set canvas size
- canvas.width = canvasSize.width;
- canvas.height = canvasSize.height;
- maskCanvas.width = canvasSize.width;
- maskCanvas.height = canvasSize.height;
- // Initialize mask canvas with black (no inpainting)
- maskCtx.fillStyle = 'black';
- maskCtx.fillRect(0, 0, canvasSize.width, canvasSize.height);
- // Update mask image
- updateMaskImage();
- }, [canvasSize]);
- const updateMaskImage = useCallback(() => {
- const maskCanvas = maskCanvasRef.current;
- if (!maskCanvas) return;
- const maskDataUrl = maskCanvas.toDataURL();
- onMaskImageChange(maskDataUrl);
- }, [onMaskImageChange]);
- const loadImageToCanvas = useCallback((base64Image: string) => {
- // Store original image for resizing
- setOriginalSourceImage(base64Image);
- setSourceImage(base64Image);
- onSourceImageChange(base64Image);
- // Load image to get dimensions and update canvas size
- const img = new Image();
- img.onload = () => {
- // Use target dimensions if provided, otherwise fit within 512x512
- let width: number;
- let height: number;
- if (targetWidth && targetHeight) {
- width = targetWidth;
- height = targetHeight;
- } else {
- // Calculate scaled dimensions to fit within 512x512 while maintaining aspect ratio
- const maxSize = 512;
- width = img.width;
- height = img.height;
- if (width > maxSize || height > maxSize) {
- const aspectRatio = width / height;
- if (width > height) {
- width = maxSize;
- height = maxSize / aspectRatio;
- } else {
- height = maxSize;
- width = maxSize * aspectRatio;
- }
- }
- }
- const newCanvasSize = { width: Math.round(width), height: Math.round(height) };
- setCanvasSize(newCanvasSize);
- // Draw image on main canvas
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
- canvas.width = width;
- canvas.height = height;
- ctx.drawImage(img, 0, 0, width, height);
- // Update mask canvas size
- const maskCanvas = maskCanvasRef.current;
- if (!maskCanvas) return;
- const maskCtx = maskCanvas.getContext('2d');
- if (!maskCtx) return;
- maskCanvas.width = width;
- maskCanvas.height = height;
- maskCtx.fillStyle = 'black';
- maskCtx.fillRect(0, 0, width, height);
- updateMaskImage();
- };
- img.src = base64Image;
- }, [onSourceImageChange, updateMaskImage, targetWidth, targetHeight]);
- // Auto-resize image when target dimensions change
- useEffect(() => {
- const resizeImage = async () => {
- if (!originalSourceImage || !targetWidth || !targetHeight) {
- return;
- }
- // Don't resize if we're already resizing
- if (isResizing) {
- return;
- }
- try {
- setIsResizing(true);
- const result = await apiClient.resizeImage(originalSourceImage, targetWidth, targetHeight);
- loadImageToCanvas(result.image);
- } catch (err) {
- console.error('Failed to resize image:', err);
- } finally {
- setIsResizing(false);
- }
- };
- resizeImage();
- }, [targetWidth, targetHeight, originalSourceImage]);
- const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
- const file = e.target.files?.[0];
- if (!file) return;
- try {
- const base64 = await fileToBase64(file);
- loadImageToCanvas(base64);
- } catch (err) {
- console.error('Failed to load image:', err);
- }
- };
- const handleUrlLoad = async () => {
- if (!urlInput.trim()) {
- setUrlError('Please enter a URL');
- return;
- }
- setIsLoadingUrl(true);
- setUrlError(null);
- try {
- const result = await validateImageUrlWithBase64(urlInput);
- if (!result.isValid) {
- setUrlError(result.error || 'Failed to load image from URL');
- setIsLoadingUrl(false);
- return;
- }
- // Use base64 data if available, otherwise use the URL directly
- const imageData = result.base64Data || urlInput;
- loadImageToCanvas(imageData);
- setIsLoadingUrl(false);
- } catch (err) {
- setUrlError(err instanceof Error ? err.message : 'Failed to load image from URL');
- setIsLoadingUrl(false);
- }
- };
- const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
- if (!sourceImage) return;
- setIsDrawing(true);
- draw(e);
- };
- const stopDrawing = () => {
- setIsDrawing(false);
- };
- const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
- if (!isDrawing || !sourceImage) return;
- const canvas = canvasRef.current;
- const maskCanvas = maskCanvasRef.current;
- if (!canvas || !maskCanvas) return;
- const ctx = canvas.getContext('2d');
- const maskCtx = maskCanvas.getContext('2d');
- if (!ctx || !maskCtx) return;
- const rect = canvas.getBoundingClientRect();
- const scaleX = canvas.width / rect.width;
- const scaleY = canvas.height / rect.height;
- const x = (e.clientX - rect.left) * scaleX;
- const y = (e.clientY - rect.top) * scaleY;
- // Draw on mask canvas (for API)
- maskCtx.globalCompositeOperation = 'source-over';
- maskCtx.fillStyle = isEraser ? 'black' : 'white';
- maskCtx.beginPath();
- maskCtx.arc(x, y, brushSize, 0, Math.PI * 2);
- maskCtx.fill();
- // Draw visual overlay directly on main canvas
- ctx.save();
- ctx.globalCompositeOperation = 'source-over';
- if (isEraser) {
- // For eraser, just redraw the image at that position
- const img = new Image();
- img.onload = () => {
- // Clear the area and redraw
- ctx.save();
- ctx.globalCompositeOperation = 'destination-out';
- ctx.beginPath();
- ctx.arc(x, y, brushSize, 0, Math.PI * 2);
- ctx.fill();
- ctx.restore();
- // Redraw the image in the cleared area
- ctx.save();
- ctx.globalCompositeOperation = 'destination-over';
- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
- ctx.restore();
- updateMaskImage();
- };
- img.src = sourceImage;
- } else {
- // For brush, draw a visible overlay
- ctx.globalAlpha = 0.6;
- ctx.fillStyle = 'rgba(255, 105, 180, 0.8)'; // Bright pink for visibility
- ctx.beginPath();
- ctx.arc(x, y, brushSize, 0, Math.PI * 2);
- ctx.fill();
- // Also draw a border for better visibility
- ctx.globalAlpha = 1.0;
- ctx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; // Red border
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.arc(x, y, brushSize, 0, Math.PI * 2);
- ctx.stroke();
- ctx.restore();
- updateMaskImage();
- }
- };
- const clearMask = () => {
- const canvas = canvasRef.current;
- const maskCanvas = maskCanvasRef.current;
- if (!canvas || !maskCanvas) return;
- const ctx = canvas.getContext('2d');
- const maskCtx = maskCanvas.getContext('2d');
- if (!ctx || !maskCtx) return;
- // Clear mask canvas
- maskCtx.fillStyle = 'black';
- maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
- // Redraw source image on main canvas
- if (sourceImage) {
- const img = new Image();
- img.onload = () => {
- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
- };
- img.src = sourceImage;
- }
- updateMaskImage();
- };
- const downloadMask = () => {
- const canvas = maskCanvasRef.current;
- if (!canvas) return;
- const link = document.createElement('a');
- link.download = 'inpainting-mask.png';
- link.href = canvas.toDataURL();
- link.click();
- };
- return (
- <div className={`space-y-4 ${className}`}>
- <Card>
- <CardContent className="pt-6">
- <div className="space-y-4">
- <div className="space-y-2">
- <Label>Source Image</Label>
- <Tabs value={inputMode} onValueChange={(value) => setInputMode(value as 'file' | 'url')}>
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="file">
- <Upload className="w-4 h-4 mr-2" />
- Upload File
- </TabsTrigger>
- <TabsTrigger value="url">
- <LinkIcon className="w-4 h-4 mr-2" />
- From URL
- </TabsTrigger>
- </TabsList>
- <TabsContent value="file" className="space-y-4 mt-4">
- <Button
- type="button"
- variant="outline"
- onClick={() => fileInputRef.current?.click()}
- className="w-full"
- >
- <Upload className="h-4 w-4 mr-2" />
- {sourceImage ? 'Change Image' : 'Upload Image'}
- </Button>
- <input
- ref={fileInputRef}
- type="file"
- accept="image/*"
- onChange={handleImageUpload}
- className="hidden"
- />
- </TabsContent>
- <TabsContent value="url" className="space-y-4 mt-4">
- <div className="space-y-2">
- <Input
- type="url"
- value={urlInput}
- onChange={(e) => {
- setUrlInput(e.target.value);
- setUrlError(null);
- }}
- placeholder="https://example.com/image.png"
- disabled={isLoadingUrl}
- />
- <p className="text-xs text-muted-foreground">
- Enter a URL that ends with an image extension (.jpg, .png, .gif, etc.)
- </p>
- </div>
- <Button
- type="button"
- variant="outline"
- onClick={handleUrlLoad}
- disabled={isLoadingUrl || !urlInput.trim()}
- className="w-full"
- >
- {isLoadingUrl ? (
- <>
- <Download className="h-4 w-4 mr-2 animate-spin" />
- Loading...
- </>
- ) : (
- <>
- <Download className="h-4 w-4 mr-2" />
- Load from URL
- </>
- )}
- </Button>
- {urlError && (
- <p className="text-sm text-destructive">{urlError}</p>
- )}
- </TabsContent>
- </Tabs>
- </div>
- {isResizing && (
- <div className="text-sm text-muted-foreground flex items-center gap-2">
- <Loader2 className="h-4 w-4 animate-spin" />
- Resizing image...
- </div>
- )}
- {sourceImage && (
- <>
- <div className="space-y-2">
- <Label>Mask Editor</Label>
- <div className="relative">
- <canvas
- ref={canvasRef}
- className="rounded-lg border border-border cursor-crosshair"
- style={{ maxWidth: '512px', height: 'auto' }}
- onMouseDown={startDrawing}
- onMouseUp={stopDrawing}
- onMouseMove={draw}
- onMouseLeave={stopDrawing}
- />
- </div>
- <p className="text-xs text-muted-foreground">
- Draw on the image to mark areas for inpainting. White areas will be inpainted, black areas will be preserved.
- </p>
- </div>
- <div className="space-y-2">
- <Label htmlFor="brush_size">
- Brush Size: {brushSize}px
- </Label>
- <input
- id="brush_size"
- type="range"
- value={brushSize}
- onChange={(e) => setBrushSize(Number(e.target.value))}
- min={1}
- max={100}
- className="w-full"
- />
- </div>
- <div className="flex gap-2">
- <Button
- type="button"
- variant={isEraser ? "default" : "outline"}
- onClick={() => setIsEraser(true)}
- className="flex-1"
- >
- <Eraser className="h-4 w-4 mr-2" />
- Eraser
- </Button>
- <Button
- type="button"
- variant={!isEraser ? "default" : "outline"}
- onClick={() => setIsEraser(false)}
- className="flex-1"
- >
- <Brush className="h-4 w-4 mr-2" />
- Brush
- </Button>
- </div>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="outline"
- onClick={clearMask}
- className="flex-1"
- >
- <RotateCcw className="h-4 w-4 mr-2" />
- Clear Mask
- </Button>
- <Button
- type="button"
- variant="outline"
- onClick={downloadMask}
- className="flex-1"
- >
- <Download className="h-4 w-4 mr-2" />
- Download Mask
- </Button>
- </div>
- </>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- );
- }
|