Network activity
Browse blocks
Image Studio
Image tools that run entirely in your browser. Remove backgrounds, upscale and restore photos, and generate captions. Each tool is self-contained and loads its model only when you first use it.
Background Remover
Cut the subject out of a photo and download it as a clean transparent PNG.
Install this block
npx shadcn@latest add @localmode/ui/blocks/image-studio/background-removerOn-device background removal - nothing leaves your browser
Drop an image to remove its background
or click to browse
PNG · JPEG · WebP · up to 10 MB
'use client';/** * @file background-remover.tsx * @description Background Remover block (`/blocks/image-studio/background-remover`) — on-device SegFormer segmentation composited to a transparent-background PNG. */import { useState } from 'react';import { Download, RotateCcw, Scissors, X } from 'lucide-react';import { useSegmentImage, toAppError, readFileAsDataUrl } from '@localmode/react';import { transformers } from '@localmode/transformers';import { MediaDropzone } from '@/components/media-dropzone';import { BeforeAfterImageViewer } from '@/components/before-after-image-viewer';import { ImageProcessingOverlay } from '@/components/image-processing-overlay';import { ConfidenceScoreBadge } from '@/components/confidence-score-badge';import { applyMaskToImage, downloadDataUrl } from '@/lib/browser-utils';const SEGMENTER_MODEL_ID = 'Xenova/segformer-b0-finetuned-ade-512-512';let segmenter: ReturnType<typeof transformers.segmenter> | null = null;const getSegmenterModel = () => (segmenter ??= transformers.segmenter(SEGMENTER_MODEL_ID));const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];export function BackgroundRemoverBlock() { const [original, setOriginal] = useState<string | null>(null); const [processed, setProcessed] = useState<string | null>(null); const [bestScore, setBestScore] = useState<number | null>(null); const [localError, setLocalError] = useState<string | null>(null); const seg = useSegmentImage({ model: getSegmenterModel() }); const processImage = async (file: File) => { setLocalError(null); try { const dataUrl = await readFileAsDataUrl(file); setOriginal(dataUrl); setProcessed(null); setBestScore(null); const result = await seg.execute(dataUrl); if (result && result.masks.length > 0) { const best = result.masks.reduce((a, b) => (b.score > a.score ? b : a)); setProcessed(await applyMaskToImage(dataUrl, best.mask)); setBestScore(best.score); } else if (result) { setLocalError('No segments were found in this image. Try another photo.'); } } catch (err) { if (err instanceof Error && err.name === 'AbortError') return; setLocalError(err instanceof Error ? err.message : String(err)); } }; const reset = () => { setOriginal(null); setProcessed(null); setBestScore(null); setLocalError(null); seg.reset(); }; const download = () => { if (processed) void downloadDataUrl(processed, `background-removed-${Date.now()}.png`); }; const error = localError ?? toAppError(seg.error)?.message ?? null; const state = seg.isLoading ? 'processing' : error ? 'error' : processed ? 'done' : 'idle'; return ( <div className="flex flex-col gap-4 p-4"> {} <div className="flex flex-wrap items-center justify-between gap-2"> <p role="status" aria-live="polite" className="text-xs text-muted-foreground"> {state === 'processing' ? 'Removing background…' : state === 'done' ? 'Background removed: transparent PNG ready' : state === 'error' ? 'Error' : 'On-device background removal - nothing leaves your browser'} </p> <div className="flex items-center gap-2"> {processed && !seg.isLoading && ( <button type="button" onClick={download} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <Download className="size-3.5" aria-hidden /> Download PNG </button> )} {original && ( <button type="button" onClick={reset} disabled={seg.isLoading} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <X className="size-3.5" aria-hidden /> Clear </button> )} </div> </div> {error && ( <div role="alert" className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2" > <p className="text-xs text-destructive">{error}</p> <button type="button" onClick={reset} className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <RotateCcw className="size-3" aria-hidden /> Dismiss & retry </button> </div> )} {!original && ( <div className="max-w-xl"> <MediaDropzone accept={ACCEPTED} multiple={false} processing={seg.isLoading} processingLabel="Removing background…" title="Drop an image to remove its background" subtitle="or click to browse" onFiles={(files) => files[0] && void processImage(files[0])} onReject={(r) => setLocalError(r[0]?.reason ?? 'That file type is not supported.')} /> </div> )} {original && !processed && ( <div className="relative max-w-xl overflow-hidden rounded-lg border border-border"> {} <img src={original} alt="Uploaded image" className={seg.isLoading ? 'w-full opacity-50' : 'w-full'} /> <ImageProcessingOverlay processing={seg.isLoading} variant="scan" icon={<Scissors className="size-5" aria-hidden />} status="Removing background…" detail={`${SEGMENTER_MODEL_ID} · first run downloads ~15MB`} onCancel={seg.cancel} /> </div> )} {original && processed && ( <div className="flex flex-col gap-3"> <div role="group" aria-label="Background removal result"> <BeforeAfterImageViewer originalSrc={original} processedSrc={processed} mode="grid" originalLabel="Original" processedLabel="Result" checkerboard originalAlt="Original image" resultAlt="Background removed: transparent PNG" /> </div> {} {bestScore != null && bestScore > 0 && ( <div className="flex items-center gap-2"> <span className="text-xs text-muted-foreground">Best mask confidence</span> <span data-score={bestScore.toFixed(4)}> <ConfidenceScoreBadge score={bestScore} label="mask" /> </span> </div> )} </div> )} </div> );}Image Enhancer
Upscale and sharpen photos, with a before-and-after compare and a download.
Install this block
npx shadcn@latest add @localmode/ui/blocks/image-studio/image-enhancerOn-device super-resolution - nothing leaves your browser
Fast lightweight 2x super-resolution (default).
Drop an image to enhance
or click to browse
PNG · JPEG · WebP · up to 10 MB
'use client';/** * @file image-enhancer.tsx * @description Image Enhancer block (`/blocks/image-studio/image-enhancer`) — Swin2SR super-resolution with a 2x / 4x / real-world Restore mode picker. */import { useState } from 'react';import { Download, RotateCcw, Sparkles, Upload, Wand2 } from 'lucide-react';import { useImageToImage, toAppError, readFileAsDataUrl, validateFile } from '@localmode/react';import { transformers } from '@localmode/transformers';import { MediaDropzone } from '@/components/media-dropzone';import { BeforeAfterImageViewer } from '@/components/before-after-image-viewer';import { ImageProcessingOverlay } from '@/components/image-processing-overlay';import { cn } from '@/lib/utils';import { imageResultToDataUrl, getImageDimensions, downloadDataUrl } from '@/lib/browser-utils';interface EnhanceMode { id: 'upscale-2x' | 'upscale-4x' | 'restore'; label: string; modelId: string; scale: number; badge: string; hint: string; size: string;}const ENHANCE_MODES: readonly EnhanceMode[] = [ { id: 'upscale-2x', label: '2x Upscale', modelId: 'Xenova/swin2SR-lightweight-x2-64', scale: 2, badge: 'Swin2SR 2x', hint: 'Fast lightweight 2x super-resolution (default).', size: '~50MB', }, { id: 'upscale-4x', label: '4x Upscale', modelId: 'Xenova/swin2SR-classical-sr-x4-64', scale: 4, badge: 'Swin2SR 4x', hint: 'Classical 4x super-resolution for a larger enlargement.', size: '~45MB', }, { id: 'restore', label: 'Restore', modelId: 'Xenova/swin2SR-realworld-sr-x4-64-bsrgan-psnr', scale: 4, badge: 'Swin2SR Restore', hint: 'Real-world restoration for old or degraded photos (4x).', size: '~50MB', },] as const;const enhancers = new Map<string, ReturnType<typeof transformers.imageToImage>>();function getEnhanceModel(modelId: string) { let model = enhancers.get(modelId); if (!model) { model = transformers.imageToImage(modelId); enhancers.set(modelId, model); } return model;}const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];interface EnhancedResult { url: string; width: number; height: number;}export function ImageEnhancerBlock() { const hooks: Record<EnhanceMode['id'], ReturnType<typeof useImageToImage>> = { 'upscale-2x': useImageToImage({ model: getEnhanceModel(ENHANCE_MODES[0].modelId), scale: ENHANCE_MODES[0].scale, }), 'upscale-4x': useImageToImage({ model: getEnhanceModel(ENHANCE_MODES[1].modelId), scale: ENHANCE_MODES[1].scale, }), restore: useImageToImage({ model: getEnhanceModel(ENHANCE_MODES[2].modelId), scale: ENHANCE_MODES[2].scale, }), }; const [modeId, setModeId] = useState<EnhanceMode['id']>('upscale-2x'); const [original, setOriginal] = useState<string | null>(null); const [results, setResults] = useState<Partial<Record<EnhanceMode['id'], EnhancedResult>>>({}); const [validationError, setValidationError] = useState<string | null>(null); const mode = ENHANCE_MODES.find((m) => m.id === modeId)!; const active = hooks[modeId]; const result = results[modeId] ?? null; const enhance = async (id: EnhanceMode['id'], src: string) => { setValidationError(null); try { const out = await hooks[id].execute(src); if (out) { const url = await imageResultToDataUrl(out.image); const dims = await getImageDimensions(url); setResults((prev) => ({ ...prev, [id]: { url, width: dims.width, height: dims.height } })); } } catch (err) { if (err instanceof Error && err.name === 'AbortError') return; } }; const processImage = async (file: File) => { const err = validateFile({ file, accept: ACCEPTED }); if (err) { setValidationError(err.message); return; } const dataUrl = await readFileAsDataUrl(file); setOriginal(dataUrl); setResults({}); await enhance(modeId, dataUrl); }; const changeMode = (id: EnhanceMode['id']) => { setModeId(id); if (original && !results[id] && !hooks[id].isLoading) void enhance(id, original); }; const reset = () => { setOriginal(null); setResults({}); setValidationError(null); active.reset(); }; const download = () => { if (result) void downloadDataUrl(result.url, `enhanced-${mode.scale}x-${Date.now()}.png`); }; const error = validationError ?? toAppError(active.error)?.message ?? null; const state = active.isLoading ? 'processing' : error ? 'error' : result ? 'done' : 'idle'; return ( <div className="flex flex-col gap-4 p-4"> {} <div className="flex flex-wrap items-center justify-between gap-2"> <p role="status" aria-live="polite" className="text-xs text-muted-foreground"> {state === 'processing' ? `Enhancing (${mode.label})…` : state === 'done' ? `Enhanced ${mode.scale}x` : state === 'error' ? 'Error' : 'On-device super-resolution - nothing leaves your browser'} </p> <div className="flex items-center gap-2"> {result && !active.isLoading && ( <span className="inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"> <Sparkles className="size-3" aria-hidden /> Enhanced {mode.scale}x </span> )} {original && ( <button type="button" onClick={reset} disabled={active.isLoading} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Upload className="size-3.5" aria-hidden /> Upload new </button> )} </div> </div> {} <div className="flex flex-col gap-1.5"> <div role="group" aria-label="Enhancement mode" className="inline-flex w-fit rounded-lg border border-border bg-muted p-1" > {ENHANCE_MODES.map((m) => ( <button key={m.id} type="button" aria-pressed={m.id === modeId} onClick={() => changeMode(m.id)} className={cn( 'inline-flex min-h-11 items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:min-h-9', m.id === modeId ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground', )} > {m.label} </button> ))} </div> <p className="text-xs text-muted-foreground">{mode.hint}</p> </div> {error && ( <div role="alert" className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2" > <p className="text-xs text-destructive">{error}</p> <button type="button" onClick={() => { setValidationError(null); active.reset(); }} className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <RotateCcw className="size-3" aria-hidden /> Dismiss & retry </button> </div> )} {!original && ( <div className="max-w-xl"> <MediaDropzone accept={ACCEPTED} multiple={false} processing={active.isLoading} processingLabel="Enhancing…" title="Drop an image to enhance" subtitle="or click to browse" onFiles={(files) => files[0] && void processImage(files[0])} onReject={(r) => setValidationError(r[0]?.reason ?? 'That file type is not supported.')} /> </div> )} {original && !result && ( <div className="relative max-w-xl overflow-hidden rounded-lg border border-border"> {} <img src={original} alt="Uploaded image" className={active.isLoading ? 'w-full opacity-50' : 'w-full'} /> <ImageProcessingOverlay processing={active.isLoading} variant="scan" icon={<Wand2 className="size-5" aria-hidden />} status={`Enhancing (${mode.label})…`} detail={`${mode.modelId} · first run downloads ${mode.size} - this may take a moment`} onCancel={active.cancel} /> </div> )} {original && result && ( <div className="grid gap-4 lg:grid-cols-[1fr_260px]"> <div role="group" aria-label="Enhanced result" data-width={result.width} data-height={result.height} data-scale={mode.scale} > <BeforeAfterImageViewer originalSrc={original} processedSrc={result.url} mode="toggle" originalLabel="Original" processedLabel="Enhanced" checkerboard={false} originalAlt="Original image" resultAlt={`Enhanced ${mode.scale}x`} /> </div> {} <div className="flex flex-col gap-3 rounded-lg border border-border p-4"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Info</p> <div className="flex items-center justify-between text-sm"> <span className="text-muted-foreground">Model</span> <span className="font-mono text-xs">{mode.badge}</span> </div> <div className="flex items-center justify-between text-sm"> <span className="text-muted-foreground">Scale</span> <span data-scale={mode.scale}>{mode.scale}x</span> </div> <div className="flex items-center justify-between text-sm"> <span className="text-muted-foreground">Output</span> <span className="tabular-nums"> {result.width}×{result.height} </span> </div> <div className="flex items-center justify-between text-sm"> <span className="text-muted-foreground">Status</span> <span className="flex items-center gap-1.5 text-primary"> <span className="size-1.5 rounded-full bg-primary" aria-hidden /> Complete </span> </div> <button type="button" onClick={download} className="mt-1 inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <Download className="size-4" aria-hidden /> Download Enhanced PNG </button> </div> </div> )} </div> );}Image Captioner
Auto-write short alt-text captions for images and collect them in a gallery.
Install this block
npx shadcn@latest add @localmode/ui/blocks/image-studio/image-captionerOn-device image captioning - nothing leaves your browser
Drop images to caption
or click to browse
JPEG · PNG · WebP · GIF · up to 10 MB
'use client';/** * @file image-captioner.tsx * @description Image Captioner block (`/blocks/image-studio/image-captioner`) — ViT-GPT2 alt-text over an accumulating gallery with copy-to-clipboard. */import { useState } from 'react';import { Check, Copy, Trash2 } from 'lucide-react';import { useOperationList, toAppError, readFileAsDataUrl, validateFile,} from '@localmode/react';import type { CaptionImageResult } from '@localmode/core';import { transformers } from '@localmode/transformers';import { MediaDropzone } from '@/components/media-dropzone';const CAPTIONER_MODEL_ID = 'Xenova/vit-gpt2-image-captioning';let captioner: ReturnType<typeof transformers.captioner> | null = null;const getCaptionerModel = () => (captioner ??= transformers.captioner(CAPTIONER_MODEL_ID));const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];const MAX_SIZE = 10 * 1024 * 1024;interface CaptionCard { id: string; src: string; caption: string; fileName: string;}export function ImageCaptionerBlock() { const [validationError, setValidationError] = useState<string | null>(null); const [pending, setPending] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null); const { items, isLoading, error: hookError, execute, cancel, reset, clearItems, removeItem } = useOperationList<[{ image: string; fileName: string }], CaptionImageResult, CaptionCard>({ fn: async (input, signal) => { const { captionImage } = await import('@localmode/core'); return captionImage({ model: getCaptionerModel(), image: input.image, abortSignal: signal }); }, transform: (result, input) => ({ id: crypto.randomUUID(), src: input.image, caption: result.caption, fileName: input.fileName, }), }); const captionFile = async (file: File) => { const err = validateFile({ file, accept: ACCEPTED, maxSize: MAX_SIZE }); if (err) { setValidationError(err.message); return; } setValidationError(null); const dataUrl = await readFileAsDataUrl(file); setPending(dataUrl); try { await execute({ image: dataUrl, fileName: file.name }); } finally { setPending(null); } }; const copy = async (id: string, caption: string) => { await navigator.clipboard.writeText(caption); setCopiedId(id); setTimeout(() => setCopiedId((cur) => (cur === id ? null : cur)), 2000); }; const error = validationError ?? toAppError(hookError)?.message ?? null; const state = isLoading ? 'processing' : error ? 'error' : items.length > 0 ? 'done' : 'idle'; return ( <div className="flex flex-col gap-4 p-4"> {} <div className="flex flex-wrap items-center justify-between gap-2"> <p role="status" aria-live="polite" className="text-xs text-muted-foreground"> {state === 'processing' ? 'Generating caption… (first run downloads ~230MB)' : items.length > 0 ? 'Captioned' : 'On-device image captioning - nothing leaves your browser'} </p> <div className="flex items-center gap-2"> {items.length > 0 && ( <span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"> {items.length} {items.length === 1 ? 'image' : 'images'} captioned </span> )} {items.length > 0 && ( <button type="button" onClick={() => clearItems()} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Trash2 className="size-3.5" aria-hidden /> Clear All </button> )} </div> </div> {error && ( <div role="alert" className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2" > <p className="text-xs text-destructive">{error}</p> <button type="button" onClick={() => { setValidationError(null); if (hookError) reset(); }} className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Dismiss </button> </div> )} {} {!isLoading && ( <div className={items.length === 0 ? 'max-w-xl' : ''}> <MediaDropzone accept={ACCEPTED} maxSize={MAX_SIZE} multiple={false} addAnother={items.length > 0} title="Drop images to caption" subtitle="or click to browse" onFiles={(files) => files[0] && void captionFile(files[0])} onReject={(r) => setValidationError(r[0]?.reason ?? 'That file is not supported.')} /> </div> )} {} {isLoading && ( <div className="flex items-center gap-3 rounded-lg border border-border p-3"> {pending ? ( <img src={pending} alt="Image being captioned" className="size-16 rounded-md object-cover opacity-70" /> ) : ( <div className="size-16 rounded-md bg-muted" /> )} <div className="flex flex-col gap-1"> <p className="text-sm font-medium">Generating caption…</p> <p className="text-xs text-muted-foreground">ViT-GPT2 is analyzing your image</p> </div> <button type="button" onClick={cancel} className="ml-auto inline-flex h-7 items-center rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Cancel </button> </div> )} {} {items.length > 0 && ( <ul aria-label="Captions" className="flex flex-col gap-2"> {items.map((item) => ( <li key={item.id} className="flex items-start gap-3 rounded-lg border border-border p-3" > {} <img src={item.src} alt="" className="size-12 shrink-0 rounded-md object-cover" /> <div className="min-w-0 flex-1"> <p className="text-sm text-foreground">{item.caption}</p> <p className="truncate text-[11px] text-muted-foreground">{item.fileName}</p> </div> <div className="flex shrink-0 items-center gap-1.5"> <button type="button" onClick={() => void copy(item.id, item.caption)} aria-label={copiedId === item.id ? 'Caption copied' : `Copy caption for ${item.fileName}`} className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > {copiedId === item.id ? ( <> <Check className="size-3.5 text-emerald-500" aria-hidden /> Copied </> ) : ( <> <Copy className="size-3.5" aria-hidden /> Copy </> )} </button> <button type="button" onClick={() => removeItem((i) => i.id === item.id)} aria-label="Delete image" className="inline-flex size-8 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-muted hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Trash2 className="size-3.5" aria-hidden /> </button> </div> </li> ))} </ul> )} </div> );}