mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add Phase 4 AI tools with Python bridge and 6 new tools
Add Python bridge (packages/ai/src/bridge.ts) that calls Python scripts via child_process with venv-first fallback to system python3. Implements 6 AI-powered tools: - Remove Background: rembg-based with U2-Net/IS-Net models - Image Upscaling: Real-ESRGAN with Lanczos fallback - OCR/Text Extraction: Tesseract + PaddleOCR engines - Face/PII Blur: MediaPipe face detection with configurable blur - Object Eraser: LaMa inpainting with mask-based input - Smart Crop: Sharp attention-based entropy cropping (no Python needed) Each tool includes: Python script, TypeScript wrapper, API route, and React settings component. All Python scripts handle ImportError gracefully with clear installation messages.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
export function BlurFacesSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("blur-faces");
|
||||
|
||||
const [blurRadius, setBlurRadius] = useState(30);
|
||||
const [sensitivity, setSensitivity] = useState(50);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {
|
||||
blurRadius,
|
||||
sensitivity: sensitivity / 100,
|
||||
});
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Blur radius */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Blur Radius</label>
|
||||
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={80}
|
||||
value={blurRadius}
|
||||
onChange={(e) => setBlurRadius(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>Light</span>
|
||||
<span>Heavy</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sensitivity */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Detection Sensitivity</label>
|
||||
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={90}
|
||||
value={sensitivity}
|
||||
onChange={(e) => setSensitivity(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>More faces</span>
|
||||
<span>Fewer false positives</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Uses MediaPipe for face detection. Automatically detects and blurs all faces in the image.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process button */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Detecting Faces..." : "Blur Faces"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2, Upload } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function EraseObjectSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
|
||||
const [maskFile, setMaskFile] = useState<File | null>(null);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [originalSize, setOriginalSize] = useState<number | null>(null);
|
||||
const [processedSize, setProcessedSize] = useState<number | null>(null);
|
||||
|
||||
const handleMaskSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0];
|
||||
if (selected) setMaskFile(selected);
|
||||
};
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0 || !maskFile) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
formData.append("mask", maskFile);
|
||||
|
||||
const res = await fetch("/api/v1/tools/erase-object", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || body.details || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setDownloadUrl(data.downloadUrl);
|
||||
setOriginalSize(data.originalSize);
|
||||
setProcessedSize(data.processedSize);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Object erasing failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mask upload */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Mask Image</label>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 mb-1.5">
|
||||
Upload a black & white mask where white areas will be erased. Create the mask in any image editor.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded border border-dashed border-border cursor-pointer hover:border-primary">
|
||||
<Upload className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{maskFile ? maskFile.name : "Select mask image..."}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleMaskSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-2 rounded bg-muted text-[10px] text-muted-foreground space-y-1">
|
||||
<p>How to create a mask:</p>
|
||||
<ol className="list-decimal list-inside space-y-0.5">
|
||||
<li>Open your image in any editor</li>
|
||||
<li>Paint white over areas to erase</li>
|
||||
<li>Keep the rest black</li>
|
||||
<li>Export as PNG and upload here</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process button */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !maskFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Erasing..." : "Erase Object"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Loader2, Copy, Check } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
type OcrEngine = "tesseract" | "paddleocr";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "de", label: "German" },
|
||||
{ code: "fr", label: "French" },
|
||||
{ code: "es", label: "Spanish" },
|
||||
{ code: "zh", label: "Chinese" },
|
||||
{ code: "ja", label: "Japanese" },
|
||||
{ code: "ko", label: "Korean" },
|
||||
];
|
||||
|
||||
export function OcrSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
|
||||
const [engine, setEngine] = useState<OcrEngine>("tesseract");
|
||||
const [language, setLanguage] = useState("en");
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [detectedEngine, setDetectedEngine] = useState<string>("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setText(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
formData.append("settings", JSON.stringify({ engine, language }));
|
||||
|
||||
const res = await fetch("/api/v1/tools/ocr", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || body.details || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setText(data.text || "");
|
||||
setDetectedEngine(data.engine || engine);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "OCR failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Engine selector */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">OCR Engine</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
onClick={() => setEngine("tesseract")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
engine === "tesseract"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Tesseract
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEngine("paddleocr")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
engine === "paddleocr"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
PaddleOCR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language selector */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Language</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Process button */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Extracting Text..." : "Extract Text"}
|
||||
</button>
|
||||
|
||||
{/* Result */}
|
||||
{text !== null && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Extracted Text ({detectedEngine})
|
||||
</label>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={text}
|
||||
rows={8}
|
||||
className="w-full px-2 py-1.5 rounded border border-border bg-muted text-xs text-foreground font-mono resize-y"
|
||||
/>
|
||||
{text.length > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{text.length} characters extracted
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
type BgModel = "u2net" | "isnet";
|
||||
|
||||
export function RemoveBgSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("remove-background");
|
||||
|
||||
const [model, setModel] = useState<BgModel>("u2net");
|
||||
const [bgColor, setBgColor] = useState("");
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { model };
|
||||
if (bgColor) settings.backgroundColor = bgColor;
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Model selector */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">AI Model</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value as BgModel)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="u2net">U2-Net (General purpose)</option>
|
||||
<option value="isnet">IS-Net (Higher accuracy)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Background color */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Replacement Background (leave empty for transparent)
|
||||
</label>
|
||||
<div className="flex gap-2 mt-0.5">
|
||||
<input
|
||||
type="color"
|
||||
value={bgColor || "#ffffff"}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
className="w-10 h-8 rounded border border-border cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={bgColor}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
placeholder="Transparent"
|
||||
className="flex-1 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
{bgColor && (
|
||||
<button
|
||||
onClick={() => setBgColor("")}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Requires Python with rembg installed. Works best with photos of people, products, and animals.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process button */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Removing Background..." : "Remove Background"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
const ASPECT_PRESETS = [
|
||||
{ label: "1:1 Square", w: 1080, h: 1080 },
|
||||
{ label: "16:9 Landscape", w: 1920, h: 1080 },
|
||||
{ label: "9:16 Portrait", w: 1080, h: 1920 },
|
||||
{ label: "4:3 Standard", w: 1440, h: 1080 },
|
||||
{ label: "3:2 Photo", w: 1620, h: 1080 },
|
||||
{ label: "Custom", w: 0, h: 0 },
|
||||
];
|
||||
|
||||
export function SmartCropSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("smart-crop");
|
||||
|
||||
const [width, setWidth] = useState("1080");
|
||||
const [height, setHeight] = useState("1080");
|
||||
const [preset, setPreset] = useState("1:1 Square");
|
||||
|
||||
const handlePreset = (label: string) => {
|
||||
setPreset(label);
|
||||
const p = ASPECT_PRESETS.find((a) => a.label === label);
|
||||
if (p && p.w > 0) {
|
||||
setWidth(String(p.w));
|
||||
setHeight(String(p.h));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
if (w > 0 && h > 0) {
|
||||
processFiles(files, { width: w, height: h });
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = Number(width) > 0 && Number(height) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Aspect ratio preset */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Target Aspect Ratio</label>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => handlePreset(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{ASPECT_PRESETS.map((p) => (
|
||||
<option key={p.label} value={p.label}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Width / Height */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => {
|
||||
setWidth(e.target.value);
|
||||
setPreset("Custom");
|
||||
}}
|
||||
min={1}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => {
|
||||
setHeight(e.target.value);
|
||||
setPreset("Custom");
|
||||
}}
|
||||
min={1}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Uses entropy-based attention detection to find the most interesting region of the image and crops to it.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Cropped: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process button */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !canProcess || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Smart Cropping..." : "Smart Crop"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
export function UpscaleSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("upscale");
|
||||
|
||||
const [scale, setScale] = useState(2);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { scale });
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Scale factor */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Scale Factor</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{[2, 4].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setScale(s)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
scale === s
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Uses Real-ESRGAN for AI upscaling when available, otherwise falls back to high-quality Lanczos interpolation.
|
||||
</p>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Upscaled: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process button */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Upscaling..." : `Upscale ${scale}x`}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,13 @@ import { FaviconSettings } from "@/components/tools/favicon-settings";
|
||||
import { ImageToPdfSettings } from "@/components/tools/image-to-pdf-settings";
|
||||
// Phase 3: Adjustments extra
|
||||
import { ReplaceColorSettings } from "@/components/tools/replace-color-settings";
|
||||
// Phase 4: AI Tools
|
||||
import { RemoveBgSettings } from "@/components/tools/remove-bg-settings";
|
||||
import { UpscaleSettings } from "@/components/tools/upscale-settings";
|
||||
import { OcrSettings } from "@/components/tools/ocr-settings";
|
||||
import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
|
||||
import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
|
||||
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
|
||||
import * as icons from "lucide-react";
|
||||
|
||||
const COLOR_TOOL_IDS = new Set([
|
||||
@@ -85,6 +92,13 @@ function ToolSettingsPanel({ toolId }: { toolId: string }) {
|
||||
if (toolId === "image-to-pdf") return <ImageToPdfSettings />;
|
||||
// Phase 3: Adjustments extra
|
||||
if (toolId === "replace-color") return <ReplaceColorSettings />;
|
||||
// Phase 4: AI Tools
|
||||
if (toolId === "remove-background") return <RemoveBgSettings />;
|
||||
if (toolId === "upscale") return <UpscaleSettings />;
|
||||
if (toolId === "ocr") return <OcrSettings />;
|
||||
if (toolId === "blur-faces") return <BlurFacesSettings />;
|
||||
if (toolId === "erase-object") return <EraseObjectSettings />;
|
||||
if (toolId === "smart-crop") return <SmartCropSettings />;
|
||||
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
|
||||
Reference in New Issue
Block a user