Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 58 additions & 13 deletions src/islands/image/BackgroundRemove.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Download } from 'lucide-react';
import type { Lang } from '@/i18n/config';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
Expand All @@ -10,7 +11,52 @@ import { downloadService } from '@/services/download';
import { formatBytes } from '@/tools/image/canvas.lib';
import { usePasteImage } from '@/hooks/usePasteImage';

export default function BackgroundRemove() {
const TR: Record<Lang, {
preparing: string;
downloadingModel: string;
removingBg: string;
removeFailed: string;
dropHere: string;
dropSub: string;
privacyNote: string;
working: string;
result: string;
transparentPng: string;
altRemoved: string;
downloadPng: string;
}> = {
en: {
preparing: 'Preparing…',
downloadingModel: 'Downloading AI model…',
removingBg: 'Removing background…',
removeFailed: 'Background removal failed.',
dropHere: 'Drop an image or click to browse',
dropSub: 'Removes the background with an on-device AI model · or paste (⌘V)',
privacyNote: "Runs entirely in your browser — the image never leaves your device. The first run downloads the AI model (~40 MB), then it's cached for next time.",
working: 'Working…',
result: 'Result',
transparentPng: 'transparent PNG',
altRemoved: 'Background removed',
downloadPng: 'Download PNG',
},
id: {
preparing: 'Menyiapkan…',
downloadingModel: 'Mengunduh model AI…',
removingBg: 'Menghapus latar belakang…',
removeFailed: 'Gagal menghapus latar belakang.',
dropHere: 'Jatuhkan gambar atau klik untuk menjelajah',
dropSub: 'Menghapus latar belakang dengan model AI di perangkat · atau tempel (⌘V)',
privacyNote: 'Berjalan sepenuhnya di browser Anda — gambar tidak pernah meninggalkan perangkat Anda. Jalankan pertama kali mengunduh model AI (~40 MB), lalu disimpan di cache untuk berikutnya.',
working: 'Memproses…',
result: 'Hasil',
transparentPng: 'PNG transparan',
altRemoved: 'Latar belakang dihapus',
downloadPng: 'Unduh PNG',
},
};

export default function BackgroundRemove({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [srcName, setSrcName] = useState('');
const [result, setResult] = useState<Blob | null>(null);
const [resultUrl, setResultUrl] = useState('');
Expand All @@ -29,7 +75,7 @@ export default function BackgroundRemove() {
setError('');
setBusy(true);
setPercent(0);
setStage('Preparing…');
setStage(t.preparing);
try {
// Loaded lazily: the library pulls in onnxruntime-web + lodash (CJS),
// which isn't server-render-safe, and this keeps it out of the initial bundle.
Expand All @@ -41,7 +87,7 @@ export default function BackgroundRemove() {
progress: (key, current, total) => {
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
setPercent(pct);
setStage(key.startsWith('fetch') ? 'Downloading AI model…' : 'Removing background…');
setStage(key.startsWith('fetch') ? t.downloadingModel : t.removingBg);
},
});
setResult(blob);
Expand All @@ -50,7 +96,7 @@ export default function BackgroundRemove() {
return URL.createObjectURL(blob);
});
} catch (e) {
setError(e instanceof Error ? e.message : 'Background removal failed.');
setError(e instanceof Error ? e.message : t.removeFailed);
} finally {
setBusy(false);
setStage('');
Expand All @@ -69,21 +115,20 @@ export default function BackgroundRemove() {
<div className="space-y-4">
<Dropzone onDrop={run} accept="image/*" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">Drop an image or click to browse</p>
<p className="text-lg font-bold">{t.dropHere}</p>
<p className="text-sm text-muted-foreground">
Removes the background with an on-device AI model · or paste (⌘V)
{t.dropSub}
</p>
</div>
</Dropzone>

<p className="text-xs text-muted-foreground">
Runs entirely in your browser — the image never leaves your device. The first run downloads
the AI model (~40&nbsp;MB), then it's cached for next time.
{t.privacyNote}
</p>

{busy && (
<div className="space-y-2">
<ProgressBar percent={percent} label={stage || 'Working…'} />
<ProgressBar percent={percent} label={stage || t.working} />
</div>
)}

Expand All @@ -92,18 +137,18 @@ export default function BackgroundRemove() {
{result && resultUrl && !busy && (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-bold uppercase tracking-wide text-muted-foreground">Result</span>
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.result}</span>
<span className="font-mono">{formatBytes(result.size)}</span>
<span className="text-muted-foreground">transparent PNG</span>
<span className="text-muted-foreground">{t.transparentPng}</span>
</div>
{/* Checkerboard makes the transparency obvious. */}
<div className="gwt-checkerboard inline-block max-w-full border-2 border-border p-1">
<img src={resultUrl} alt="Background removed" className="block max-h-[70vh] w-auto max-w-full" />
<img src={resultUrl} alt={t.altRemoved} className="block max-h-[70vh] w-auto max-w-full" />
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={download}>
<Download className="h-4 w-4" />
Download PNG
{t.downloadPng}
</Button>
<CopyImageButton blob={result} />
<EditInAnnotatorButton blob={result} filename={(srcName.replace(/\.[^.]+$/, '') || 'image') + '-no-bg.png'} />
Expand Down
29 changes: 23 additions & 6 deletions src/islands/image/CameraCapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,31 @@ import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import { useCamera } from '@/hooks/useCamera';
import { frameToFile } from '@/tools/image/camera.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
useDeviceCamera: string; cancel: string; capturing: string; capture: string; switchCamera: string;
}> = {
en: {
useDeviceCamera: 'Use device camera', cancel: 'Cancel', capturing: 'Capturing…',
capture: 'Capture', switchCamera: 'Switch camera',
},
id: {
useDeviceCamera: 'Gunakan kamera perangkat', cancel: 'Batal', capturing: 'Mengambil…',
capture: 'Ambil', switchCamera: 'Ganti kamera',
},
};

export default function CameraCapture({
onCapture,
onCancel,
lang = 'en',
}: {
onCapture: (file: File) => void;
onCancel: () => void;
lang?: Lang;
}) {
const t = TR[lang] ?? TR.en;
const { videoRef, stream, error, hasMultiple, start, stop, switchCamera } = useCamera();
const fileInputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
Expand Down Expand Up @@ -50,19 +67,19 @@ export default function CameraCapture({
<div className="space-y-2">
<Alert variant="error">{error.message}</Alert>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={useDeviceCamera}>Use device camera</Button>
<Button variant="ghost" onClick={cancel}>Cancel</Button>
<Button variant="secondary" onClick={useDeviceCamera}>{t.useDeviceCamera}</Button>
<Button variant="ghost" onClick={cancel}>{t.cancel}</Button>
</div>
</div>
) : (
<div className="space-y-3">
<video ref={videoRef} playsInline muted className="max-h-96 w-auto border-2 border-border" />
<div className="flex flex-wrap gap-2">
<Button onClick={capture} disabled={busy || !stream}>{busy ? 'Capturing…' : 'Capture'}</Button>
{hasMultiple && <Button variant="secondary" onClick={switchCamera}>Switch camera</Button>}
<Button onClick={capture} disabled={busy || !stream}>{busy ? t.capturing : t.capture}</Button>
{hasMultiple && <Button variant="secondary" onClick={switchCamera}>{t.switchCamera}</Button>}
{/* Always available — opens the OS camera app on phones. */}
<Button variant="secondary" onClick={useDeviceCamera}>Use device camera</Button>
<Button variant="ghost" onClick={cancel}>Cancel</Button>
<Button variant="secondary" onClick={useDeviceCamera}>{t.useDeviceCamera}</Button>
<Button variant="ghost" onClick={cancel}>{t.cancel}</Button>
</div>
</div>
)}
Expand Down
14 changes: 11 additions & 3 deletions src/islands/image/CameraTool.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { useState } from 'react';
import type { Lang } from '@/i18n/config';
import { Button } from '@/components/ui/Button';
import { ImageResult } from '@/components/ui/ImageResult';
import CameraCapture from './CameraCapture';

export default function CameraTool() {
const TR: Record<Lang, { openCamera: string; retake: string }> = {
en: { openCamera: 'Open camera', retake: 'Retake' },
id: { openCamera: 'Buka kamera', retake: 'Ambil ulang' },
};

export default function CameraTool({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [photo, setPhoto] = useState<File | null>(null);
const [capturing, setCapturing] = useState(true);

Expand All @@ -13,20 +20,21 @@ export default function CameraTool() {
<div className="space-y-4">
{capturing && (
<CameraCapture
lang={lang}
onCapture={(file) => { setPhoto(file); setCapturing(false); }}
onCancel={() => setCapturing(false)}
/>
)}

{!capturing && !photo && (
<Button onClick={() => setCapturing(true)}>Open camera</Button>
<Button onClick={() => setCapturing(true)}>{t.openCamera}</Button>
)}

{photo && (
<div className="space-y-2">
{/* ImageResult already renders Download / Copy image / Edit in Annotator. */}
<ImageResult blob={photo} filename={photo.name} />
<Button variant="secondary" onClick={retake}>Retake</Button>
<Button variant="secondary" onClick={retake}>{t.retake}</Button>
</div>
)}
</div>
Expand Down
76 changes: 58 additions & 18 deletions src/islands/image/FaceBlur.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import { Download } from 'lucide-react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
Expand All @@ -9,6 +9,7 @@ import { downloadService } from '@/services/download';
import { formatBytes } from '@/tools/image/canvas.lib';
import { expandBox, type Box } from '@/tools/image/face-blur.lib';
import { usePasteImage } from '@/hooks/usePasteImage';
import type { Lang } from '@/i18n/config';

type Effect = 'blur' | 'pixelate' | 'solid';

Expand All @@ -18,6 +19,46 @@ const EFFECTS: { key: Effect; label: string }[] = [
{ key: 'solid', label: 'Solid' },
];

const TR: Record<Lang, {
dropTitle: string; dropDesc: string; effect: string; effects: Record<Effect, string>;
privacy: ReactNode; loadingDetector: string; detecting: string; working: string;
errProcess: string; noFaces: string; result: string; facesHidden: (n: number) => string;
altBlurred: string; downloadPng: string;
}> = {
en: {
dropTitle: 'Drop an image or click to browse',
dropDesc: 'Detects and hides faces with on-device AI · or paste (⌘V)',
effect: 'Effect',
effects: { blur: 'Blur', pixelate: 'Pixelate', solid: 'Solid' },
privacy: <>Runs entirely in your browser — the image never leaves your device. Detection is automatic; for privacy-critical images, prefer <strong>Solid</strong> (blur/pixelate can be partly reversed). The first run downloads a small model, then it's cached.</>,
loadingDetector: 'Loading face detector…',
detecting: 'Detecting faces…',
working: 'Working…',
errProcess: 'Could not process this image.',
noFaces: 'No faces detected in this image.',
result: 'Result',
facesHidden: (n) => `${n} face${n === 1 ? '' : 's'} hidden`,
altBlurred: 'Faces blurred',
downloadPng: 'Download PNG',
},
id: {
dropTitle: 'Letakkan gambar atau klik untuk memilih',
dropDesc: 'Mendeteksi dan menyembunyikan wajah dengan AI di perangkat · atau tempel (⌘V)',
effect: 'Efek',
effects: { blur: 'Blur', pixelate: 'Piksel', solid: 'Blok' },
privacy: <>Berjalan sepenuhnya di browser Anda — gambar tidak pernah meninggalkan perangkat Anda. Deteksi berjalan otomatis; untuk gambar yang sangat sensitif, pilih <strong>Blok</strong> (blur/piksel bisa sebagian dipulihkan). Penggunaan pertama mengunduh model kecil, lalu di-cache.</>,
loadingDetector: 'Memuat pendeteksi wajah…',
detecting: 'Mendeteksi wajah…',
working: 'Memproses…',
errProcess: 'Tidak dapat memproses gambar ini.',
noFaces: 'Tidak ada wajah terdeteksi pada gambar ini.',
result: 'Hasil',
facesHidden: (n) => `${n} wajah disembunyikan`,
altBlurred: 'Wajah diburamkan',
downloadPng: 'Unduh PNG',
},
};

// Cache the detector across runs so the model loads once.
let detectorPromise: Promise<{ detect: (img: ImageBitmap) => { detections: { boundingBox?: { originX: number; originY: number; width: number; height: number } }[] } }> | null = null;

Expand Down Expand Up @@ -68,7 +109,8 @@ function obscure(ctx: CanvasRenderingContext2D, bmp: ImageBitmap, box: Box, effe
ctx.restore();
}

export default function FaceBlur() {
export default function FaceBlur({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [effect, setEffect] = useState<Effect>('blur');
const [srcName, setSrcName] = useState('');
const [result, setResult] = useState<Blob | null>(null);
Expand All @@ -88,10 +130,10 @@ export default function FaceBlur() {
setError('');
setBusy(true);
try {
setStage('Loading face detector…');
setStage(t.loadingDetector);
const detector = await getDetector();
const bmp = await createImageBitmap(file);
setStage('Detecting faces…');
setStage(t.detecting);
const { detections } = detector.detect(bmp);

const canvas = document.createElement('canvas');
Expand Down Expand Up @@ -123,7 +165,7 @@ export default function FaceBlur() {
return URL.createObjectURL(blob);
});
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not process this image.');
setError(e instanceof Error ? e.message : t.errProcess);
} finally {
setBusy(false);
setStage('');
Expand Down Expand Up @@ -153,15 +195,15 @@ export default function FaceBlur() {
<div className="space-y-4">
<Dropzone onDrop={onDrop} accept="image/*" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">Drop an image or click to browse</p>
<p className="text-lg font-bold">{t.dropTitle}</p>
<p className="text-sm text-muted-foreground">
Detects and hides faces with on-device AI · or paste (⌘V)
{t.dropDesc}
</p>
</div>
</Dropzone>

<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Effect</span>
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">{t.effect}</span>
<div className="flex flex-wrap gap-2">
{EFFECTS.map(e => (
<Button
Expand All @@ -171,37 +213,35 @@ export default function FaceBlur() {
onClick={() => changeEffect(e.key)}
disabled={busy}
>
{e.label}
{t.effects[e.key]}
</Button>
))}
</div>
</div>

<p className="text-xs text-muted-foreground">
Runs entirely in your browser — the image never leaves your device. Detection is automatic;
for privacy-critical images, prefer <strong>Solid</strong> (blur/pixelate can be partly
reversed). The first run downloads a small model, then it's cached.
{t.privacy}
</p>

{busy && <p className="text-sm text-muted-foreground">{stage || 'Working…'}</p>}
{busy && <p className="text-sm text-muted-foreground">{stage || t.working}</p>}
{error && <Alert variant="error">{error}</Alert>}

{faceCount === 0 && !busy && !error && (
<Alert variant="error">No faces detected in this image.</Alert>
<Alert variant="error">{t.noFaces}</Alert>
)}

{result && resultUrl && !busy && faceCount !== null && faceCount > 0 && (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-bold uppercase tracking-wide text-muted-foreground">Result</span>
<span>{faceCount} face{faceCount === 1 ? '' : 's'} hidden</span>
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.result}</span>
<span>{t.facesHidden(faceCount)}</span>
<span className="font-mono text-muted-foreground">{formatBytes(result.size)}</span>
</div>
<img src={resultUrl} alt="Faces blurred" className="block max-h-[70vh] w-auto max-w-full border-2 border-border" />
<img src={resultUrl} alt={t.altBlurred} className="block max-h-[70vh] w-auto max-w-full border-2 border-border" />
<div className="flex flex-wrap gap-2">
<Button onClick={download}>
<Download className="h-4 w-4" />
Download PNG
{t.downloadPng}
</Button>
<CopyImageButton blob={result} />
<EditInAnnotatorButton blob={result} filename={(srcName.replace(/\.[^.]+$/, '') || 'image') + '-blurred.png'} />
Expand Down
Loading
Loading