KI-Bildbearbeitung
Ein Bild mit KI bearbeiten. Einen Bereich übermalen und sagen, was dort hin soll, vorher und nachher vergleichen, eines von mehreren Ergebnissen für den Bereich wählen, durch die Bearbeitungen zurückgehen und den Look mit Reglern einstellen. Jedes bearbeitete Bild ist gekennzeichnet, nichts bleibt, bevor du entscheidest.
Einen Bereich ändern, den Rest behalten
Die KI-Bildbearbeitung ändert ein vorhandenes Bild Bereich für Bereich, statt ein neues zu erzeugen. Nichts bleibt, bevor du entscheidest, und jedes bearbeitete Bild trägt eine KI-Kennzeichnung, das Ergebnis bleibt also ehrlich, wo immer es landet.
„Übermalen und ersetzen“ bringt Pinsel und Radierer auf das Bild, fertige Bereiche für die Tastatur und einen Prompt für das, was dort hin soll, das Ergebnis wächst aus dem markierten Bereich. „Vorher und nachher“ vergleicht mit einem Regler, der mit Zeiger und Pfeiltasten läuft, oder zeigt beide Bilder nebeneinander, der bearbeitete Bereich lässt sich umranden. „Ergebnis wählen“ legt vier Ergebnisse für denselben Bereich neben das Original, mit Zoom auf den Bereich und neuen Ergebnissen auf Wunsch. Der „Bearbeitungsverlauf“ führt jede Bearbeitung als Schritt mit Werkzeug, Prompt und Uhrzeit, jeder Schritt lässt sich wiederherstellen, spätere werden beiseitegelegt statt gelöscht. „Look einstellen“ bietet Vorgaben und Regler für Wärme, Kontrast, Farbe und Weichheit, gedrückt halten zeigt das Original.
Lass Menschen zuerst den Bereich markieren und dann den Prompt schreiben, so trifft das Ergebnis eher, was gemeint war. Stell Behalten und Verwerfen als zwei gleichwertige Knöpfe nebeneinander. Die Component zeichnet die Oberfläche, das Bildmodell deiner Wahl bindest du über Props an.
Variants
Details
Was KI-Bildbearbeitung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Schreiben und Erstellen
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/ai-image-editor.tsx
- Bringt mit
- utilskiticonrevealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 65 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AiImageEditorProps aus components/ui/ai-image-editor.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAiImageEditorVariantinpaintbefore-aftervariantshistorystyle-controls
titleoptionalstringdescriptionoptionalstringimageoptionalReact.ReactNodeThe image before the edit
resultoptionalReact.ReactNodeThe image after the edit
promptoptionalstringThe edit in words
maskoptionalImageEditMaskThe edited area
regionsoptionalImageEditRegion[]inpaint: areas to select without drawing
optionsoptionalImageEditOption[]variants: results for the area to choose from
stepsoptionalImageEditStep[]history: the edits so far, the first is the original
stylesoptionalImageEditStyle[]style-controls
presetsoptionalImageEditPreset[]modeloptionalstringe.g. "Atlas Image 3"
costoptionalstringCredits one run costs, shown on the button
noteoptionalstringLine under the editor, e.g. on usage rights
onGenerateoptional(prompt: string, mask: ImageEditMask | null) => voidonActionoptional(action: 'keep' | 'discard' | 'apply' | 'restore', id?: string) => voidKeep, discard, apply an option, restore a step
Eingebaute Texte
Diese Texte schreibt KI-Bildbearbeitung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 65 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| canvas | Englisch Image to edit | Deutsch Bild zum Bearbeiten |
| tools | Englisch Tools | Deutsch Werkzeuge |
| brush | Englisch Brush | Deutsch Pinsel |
| eraser | Englisch Eraser | Deutsch Radierer |
| size | Englisch Brush size | Deutsch Pinselgröße |
| undo | Englisch Undo stroke | Deutsch Strich zurücknehmen |
| clear | Englisch Clear area | Deutsch Bereich leeren |
| areas | Englisch Select an area | Deutsch Bereich wählen |
| areaOn | Englisch {name} selected | Deutsch {name} gewählt |
| paintHint | Englisch Paint over the part you want to change | Deutsch Male über den Teil, der sich ändern soll |
| marked | Englisch Area marked | Deutsch Bereich markiert |
| noMask | Englisch Mark an area first | Deutsch Markier zuerst einen Bereich |
| ask | Englisch What should go there? | Deutsch Was soll dort hin? |
| askPlaceholder | Englisch A tall plant in a stone pot | Deutsch Eine hohe Pflanze im Steintopf |
| generate | Englisch Generate | Deutsch Erzeugen |
| generating | Englisch Generating | Deutsch Wird erzeugt |
| keep | Englisch Keep | Deutsch Behalten |
| kept | Englisch Kept | Deutsch Behalten |
| discard | Englisch Discard | Deutsch Verwerfen |
| discarded | Englisch Back to the original | Deutsch Zurück zum Original |
| compare | Englisch Hold to see the original | Deutsch Gedrückt halten für das Original |
| compareShort | Englisch Original | Deutsch Original |
| aiEdited | Englisch Edited with AI | Deutsch Mit KI bearbeitet |
| before | Englisch Before | Deutsch Vorher |
| after | Englisch After | Deutsch Nachher |
| divider | Englisch Compare before and after | Deutsch Vorher und nachher vergleichen |
| slider | Englisch Slider | Deutsch Regler |
| sideBySide | Englisch Side by side | Deutsch Nebeneinander |
| view | Englisch View | Deutsch Ansicht |
| showArea | Englisch Show the edited area | Deutsch Bearbeiteten Bereich zeigen |
| edit | Englisch The edit | Deutsch Die Änderung |
| model | Englisch Model | Deutsch Modell |
| decide | Englisch Your decision | Deutsch Deine Entscheidung |
| decideText | Englisch Nothing changes in your files until you keep the result. | Deutsch An deinen Dateien ändert sich erst etwas, wenn du das Ergebnis behältst. |
| options | Englisch Results for this area | Deutsch Ergebnisse für diesen Bereich |
| option | Englisch Use {name} | Deutsch {name} verwenden |
| original | Englisch Original | Deutsch Original |
| keepOriginal | Englisch Keep the original | Deutsch Original behalten |
| more | Englisch New results | Deutsch Neue Ergebnisse |
| apply | Englisch Use this result | Deutsch Dieses Ergebnis verwenden |
| applied | Englisch Result in the image | Deutsch Ergebnis im Bild |
| zoom | Englisch Show only the area | Deutsch Nur den Bereich zeigen |
| steps | Englisch Edits | Deutsch Bearbeitungen |
| step | Englisch Step {n} | Deutsch Schritt {n} |
| current | Englisch Current | Deutsch Aktuell |
| viewing | Englisch Viewing step {n} | Deutsch Ansicht von Schritt {n} |
| restore | Englisch Restore this step | Deutsch Diesen Schritt wiederherstellen |
| restored | Englisch Restored step {n}. Later edits are kept below, in case you change your mind. | Deutsch Schritt {n} wiederhergestellt. Spätere Bearbeitungen bleiben unten erhalten, falls du es dir anders überlegst. |
| toCurrent | Englisch Back to the latest | Deutsch Zur neuesten Fassung |
| dropped | Englisch Set aside | Deutsch Beiseitegelegt |
| redo | Englisch Bring back | Deutsch Zurückholen |
| original_ | Englisch Original | Deutsch Original |
| erase | Englisch Removed | Deutsch Entfernt |
| fill | Englisch Filled in | Deutsch Ergänzt |
| expand | Englisch Extended | Deutsch Erweitert |
| style | Englisch Style | Deutsch Stil |
| upscale | Englisch Upscaled | Deutsch Vergrößert |
| presets | Englisch Looks | Deutsch Looks |
| reset | Englisch Reset | Deutsch Zurücksetzen |
| preview | Englisch Rough preview. The real result comes when you generate. | Deutsch Grobe Vorschau. Das echte Ergebnis entsteht beim Erzeugen. |
| seed | Englisch Keep the composition | Deutsch Bildaufbau beibehalten |
| seedText | Englisch Same layout and objects, only the look changes. | Deutsch Gleicher Aufbau und gleiche Dinge, nur der Look ändert sich. |
| generateWith | Englisch Generate with these settings | Deutsch Mit diesen Werten erzeugen |
| done | Englisch New version ready | Deutsch Neue Fassung fertig |
| honest | Englisch AI can invent details. Check faces, text and signs before you use the image. | Deutsch KI kann Details erfinden. Prüfe Gesichter, Schrift und Schilder, bevor du das Bild nutzt. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- KI-Bildbearbeitung reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Bild hoch, Bild runter und Leertaste.
- Setzt aria-checked, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, img, radio, radiogroup und slider.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.226 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { useScrollFade } from '@/components/ui/motion';
export type AiImageEditorVariant = 'inpaint' | 'before-after' | 'variants' | 'history' | 'style-controls';
/** An area of the image in percent of its width and height */
export interface ImageEditMask {
x: number;
y: number;
w: number;
h: number;
shape?: 'ellipse' | 'rect';
}
/** inpaint: a ready made area to select with one click or the keyboard */
export interface ImageEditRegion {
id: string;
label: string;
mask: ImageEditMask;
}
/** variants: one result for the masked area */
export interface ImageEditOption {
id: string;
label: string;
media?: React.ReactNode;
}
export type ImageEditTool = 'original' | 'erase' | 'fill' | 'expand' | 'style' | 'upscale';
/** history: one edit, oldest first */
export interface ImageEditStep {
id: string;
tool: ImageEditTool;
/** What was asked, e.g. "Remove the boat" */
prompt: string;
/** e.g. "10:42" */
time?: string;
media?: React.ReactNode;
}
/** style-controls: one slider */
export interface ImageEditStyle {
id: string;
label: string;
/** 0 to 100 */
value: number;
/** Words at both ends of the slider, e.g. "Close to the original" and "Free" */
low?: string;
high?: string;
/** What the slider changes in the rough preview */
effect?: 'warmth' | 'contrast' | 'saturation' | 'softness' | 'none';
}
/** style-controls: a named set of slider values */
export interface ImageEditPreset {
id: string;
label: string;
values: Record<string, number>;
}
export interface AiImageEditorProps {
variant?: AiImageEditorVariant;
title?: string;
description?: string;
/** The image before the edit */
image?: React.ReactNode;
/** The image after the edit */
result?: React.ReactNode;
/** The edit in words */
prompt?: string;
/** The edited area */
mask?: ImageEditMask;
/** inpaint: areas to select without drawing */
regions?: ImageEditRegion[];
/** variants: results for the area to choose from */
options?: ImageEditOption[];
/** history: the edits so far, the first is the original */
steps?: ImageEditStep[];
/** style-controls */
styles?: ImageEditStyle[];
presets?: ImageEditPreset[];
/** e.g. "Atlas Image 3" */
model?: string;
/** Credits one run costs, shown on the button */
cost?: string;
/** Line under the editor, e.g. on usage rights */
note?: string;
onGenerate?: (prompt: string, mask: ImageEditMask | null) => void;
/** Keep, discard, apply an option, restore a step */
onAction?: (action: 'keep' | 'discard' | 'apply' | 'restore', id?: string) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
canvas: 'Image to edit',
tools: 'Tools',
brush: 'Brush',
eraser: 'Eraser',
size: 'Brush size',
undo: 'Undo stroke',
clear: 'Clear area',
areas: 'Select an area',
areaOn: '{name} selected',
paintHint: 'Paint over the part you want to change',
marked: 'Area marked',
noMask: 'Mark an area first',
ask: 'What should go there?',
askPlaceholder: 'A tall plant in a stone pot',
generate: 'Generate',
generating: 'Generating',
keep: 'Keep',
kept: 'Kept',
discard: 'Discard',
discarded: 'Back to the original',
compare: 'Hold to see the original',
compareShort: 'Original',
aiEdited: 'Edited with AI',
before: 'Before',
after: 'After',
divider: 'Compare before and after',
slider: 'Slider',
sideBySide: 'Side by side',
view: 'View',
showArea: 'Show the edited area',
edit: 'The edit',
model: 'Model',
decide: 'Your decision',
decideText: 'Nothing changes in your files until you keep the result.',
options: 'Results for this area',
option: 'Use {name}',
original: 'Original',
keepOriginal: 'Keep the original',
more: 'New results',
apply: 'Use this result',
applied: 'Result in the image',
zoom: 'Show only the area',
steps: 'Edits',
step: 'Step {n}',
current: 'Current',
viewing: 'Viewing step {n}',
restore: 'Restore this step',
restored: 'Restored step {n}. Later edits are kept below, in case you change your mind.',
toCurrent: 'Back to the latest',
dropped: 'Set aside',
redo: 'Bring back',
original_: 'Original',
erase: 'Removed',
fill: 'Filled in',
expand: 'Extended',
style: 'Style',
upscale: 'Upscaled',
presets: 'Looks',
reset: 'Reset',
preview: 'Rough preview. The real result comes when you generate.',
seed: 'Keep the composition',
seedText: 'Same layout and objects, only the look changes.',
generateWith: 'Generate with these settings',
done: 'New version ready',
honest: 'AI can invent details. Check faces, text and signs before you use the image.',
},
de: {
canvas: 'Bild zum Bearbeiten',
tools: 'Werkzeuge',
brush: 'Pinsel',
eraser: 'Radierer',
size: 'Pinselgröße',
undo: 'Strich zurücknehmen',
clear: 'Bereich leeren',
areas: 'Bereich wählen',
areaOn: '{name} gewählt',
paintHint: 'Male über den Teil, der sich ändern soll',
marked: 'Bereich markiert',
noMask: 'Markier zuerst einen Bereich',
ask: 'Was soll dort hin?',
askPlaceholder: 'Eine hohe Pflanze im Steintopf',
generate: 'Erzeugen',
generating: 'Wird erzeugt',
keep: 'Behalten',
kept: 'Behalten',
discard: 'Verwerfen',
discarded: 'Zurück zum Original',
compare: 'Gedrückt halten für das Original',
compareShort: 'Original',
aiEdited: 'Mit KI bearbeitet',
before: 'Vorher',
after: 'Nachher',
divider: 'Vorher und nachher vergleichen',
slider: 'Regler',
sideBySide: 'Nebeneinander',
view: 'Ansicht',
showArea: 'Bearbeiteten Bereich zeigen',
edit: 'Die Änderung',
model: 'Modell',
decide: 'Deine Entscheidung',
decideText: 'An deinen Dateien ändert sich erst etwas, wenn du das Ergebnis behältst.',
options: 'Ergebnisse für diesen Bereich',
option: '{name} verwenden',
original: 'Original',
keepOriginal: 'Original behalten',
more: 'Neue Ergebnisse',
apply: 'Dieses Ergebnis verwenden',
applied: 'Ergebnis im Bild',
zoom: 'Nur den Bereich zeigen',
steps: 'Bearbeitungen',
step: 'Schritt {n}',
current: 'Aktuell',
viewing: 'Ansicht von Schritt {n}',
restore: 'Diesen Schritt wiederherstellen',
restored: 'Schritt {n} wiederhergestellt. Spätere Bearbeitungen bleiben unten erhalten, falls du es dir anders überlegst.',
toCurrent: 'Zur neuesten Fassung',
dropped: 'Beiseitegelegt',
redo: 'Zurückholen',
original_: 'Original',
erase: 'Entfernt',
fill: 'Ergänzt',
expand: 'Erweitert',
style: 'Stil',
upscale: 'Vergrößert',
presets: 'Looks',
reset: 'Zurücksetzen',
preview: 'Grobe Vorschau. Das echte Ergebnis entsteht beim Erzeugen.',
seed: 'Bildaufbau beibehalten',
seedText: 'Gleicher Aufbau und gleiche Dinge, nur der Look ändert sich.',
generateWith: 'Mit diesen Werten erzeugen',
done: 'Neue Fassung fertig',
honest: 'KI kann Details erfinden. Prüfe Gesichter, Schrift und Schilder, bevor du das Bild nutzt.',
},
};
type Texts = (typeof TEXTS)['en'];
type P = AiImageEditorProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
const [state, setState] = useState(value);
const key = JSON.stringify(value, (k, v) => (k === 'media' ? undefined : v));
const seen = useRef(key);
if (seen.current !== key) {
seen.current = key;
setState(value);
}
return [state, setState] as const;
}
const CSS =
'@keyframes aie-ants{to{stroke-dashoffset:-16}}@keyframes aie-up{from{opacity:0;transform:translateY(6px)}}@keyframes aie-sheen{from{transform:translateX(-120%)}to{transform:translateX(120%)}}@keyframes aie-develop{from{filter:blur(14px) saturate(.5)}}' +
'@media (prefers-reduced-motion:no-preference){.aie-ants{animation:aie-ants 1.2s linear infinite}.aie-up{animation:aie-up .4s var(--ease-kit) var(--aie-d,0ms) both}.aie-sheen{animation:aie-sheen 1.5s ease-in-out infinite}.aie-develop{animation:aie-develop 1s var(--ease-kit) both}.aie-reveal{transition:clip-path 1s var(--ease-kit)}}';
function Style() {
return (
<style href="kit-ai-image-editor" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
const TOOL_ICON: Record<ImageEditTool, IconName> = { original: 'image', erase: 'eraser', fill: 'brush', expand: 'maximize', style: 'palette', upscale: 'zoom-in' };
const btn = 'inline-flex h-11 shrink-0 items-center whitespace-nowrap justify-center gap-1.5 rounded-lg px-3.5 text-[13px] font-medium transition-[background-color,color,opacity] duration-200 disabled:pointer-events-none disabled:opacity-50 sm:h-9';
const primaryBtn = cn(btn, 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');
const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
function Stage({ title, description, children, className }: { title?: string; description?: string; children: React.ReactNode; className?: string }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
<div className="kit-container">
<div className="mx-auto w-full max-w-[68rem]">
{(title || description) && (
<Reveal className="mb-6 sm:mb-8">
{title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
{description && <p className="mt-2 max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
/** The mark that says the picture was changed by AI */
function AiMark({ t, className }: { t: Texts; className?: string }) {
return (
<span className={cn('pointer-events-none absolute bottom-2.5 left-2.5 z-[3] inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,black_58%,transparent)] px-1.5 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm', className)}>
<Icon name="sparkle" size={11} />
{t.aiEdited}
</span>
);
}
/** A label on the image, top left or right */
function Tag({ children, side = 'left', className }: { children: React.ReactNode; side?: 'left' | 'right'; className?: string }) {
return <span className={cn('pointer-events-none absolute top-2.5 z-[3] rounded-md bg-[color-mix(in_oklab,black_58%,transparent)] px-2 py-1 text-[11.5px] font-medium text-white backdrop-blur-sm', side === 'left' ? 'left-2.5' : 'right-2.5', className)}>{children}</span>;
}
/** The image fills its layer like object-fit cover */
function Layer({ children, className, style }: { children?: React.ReactNode; className?: string; style?: React.CSSProperties }) {
return (
<div data-clip-ok className={cn('absolute inset-0 [&>*]:size-full', className)} style={style}>
{children}
</div>
);
}
/** The outline of the edited area with marching ants */
function MaskOutline({ mask, fillArea = false }: { mask: ImageEditMask; fillArea?: boolean }) {
const common = { fill: fillArea ? 'color-mix(in oklab, var(--primary) 26%, transparent)' : 'none', stroke: 'white', strokeWidth: 1.6, vectorEffect: 'non-scaling-stroke' as const };
const shape = (extra: React.SVGProps<SVGEllipseElement & SVGRectElement>) =>
mask.shape === 'rect' ? <rect x={mask.x} y={mask.y} width={mask.w} height={mask.h} rx={1.5} {...common} {...extra} /> : <ellipse cx={round(mask.x + mask.w / 2)} cy={round(mask.y + mask.h / 2)} rx={round(mask.w / 2)} ry={round(mask.h / 2)} {...common} {...extra} />;
return (
<svg aria-hidden viewBox="0 0 100 100" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 z-[2] size-full">
{shape({ stroke: 'rgb(0 0 0 / 0.55)', fill: common.fill })}
{shape({ strokeDasharray: '5 3', className: 'aie-ants', fill: 'none' } as never)}
</svg>
);
}
/** Grain and a sheen over an area while it renders */
function Rendering({ mask, progress }: { mask?: ImageEditMask | null; progress?: number }) {
const box = mask ?? { x: 0, y: 0, w: 100, h: 100 };
return (
<div aria-hidden className="pointer-events-none absolute inset-0 z-[2]">
<div className={cn('absolute overflow-hidden bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] backdrop-blur-md', mask?.shape === 'rect' ? 'rounded-md' : 'rounded-[50%]')} style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }}>
<div className="absolute inset-0 bg-[radial-gradient(rgb(255_255_255/0.28)_1px,transparent_1.5px)] [background-size:6px_6px]" />
<div className="aie-sheen absolute inset-y-0 w-2/3 bg-[linear-gradient(100deg,transparent,rgb(255_255_255/0.35),transparent)]" />
</div>
{progress !== undefined && (
<div className="absolute inset-x-3 bottom-3 h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,black_40%,transparent)]">
<div className="h-full origin-left rounded-full bg-primary transition-transform duration-150" style={{ transform: `scaleX(${round(progress / 100, 3)})` }} />
</div>
)}
</div>
);
}
/** Runs a fake render with progress, then calls done */
function useRender() {
const [progress, setProgress] = useState<number | null>(null);
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearInterval(timer.current), []);
const start = (done: () => void) => {
if (prefersReducedMotion()) return done();
setProgress(0);
window.clearInterval(timer.current);
let p = 0;
timer.current = window.setInterval(() => {
p += 5;
if (p < 100) return setProgress(p);
window.clearInterval(timer.current);
setProgress(null);
done();
}, 60);
};
return { progress, busy: progress !== null, start };
}
/** Center of a mask in percent */
const center = (m?: ImageEditMask | null) => (m ? { x: round(m.x + m.w / 2), y: round(m.y + m.h / 2) } : { x: 50, y: 50 });
/** A button you hold to see the original */
function HoldCompare({ t, on, className }: { t: Texts; on: (v: boolean) => void; className?: string }) {
return (
<button
type="button"
onPointerDown={() => on(true)}
onPointerUp={() => on(false)}
onPointerLeave={() => on(false)}
onPointerCancel={() => on(false)}
onKeyDown={(e) => (e.key === ' ' || e.key === 'Enter') && (e.preventDefault(), on(true))}
onKeyUp={() => on(false)}
onBlur={() => on(false)}
title={t.compare}
className={cn(quietBtn, 'select-none touch-none', className)}
>
<Icon name="eye" size={15} />
<span>{t.compareShort}</span>
<span className="sr-only">. {t.compare}</span>
</button>
);
}
function Note({ children }: { children: React.ReactNode }) {
return (
<p className="mt-4 flex items-start gap-2 px-1 text-[12.5px] leading-snug text-muted-foreground">
<Icon name="info" size={14} className="mt-px shrink-0" />
<span>{children}</span>
</p>
);
}
/* ------------------------------------------------------------------ */
/* inpaint */
/* ------------------------------------------------------------------ */
type Stroke = { id: number; erase: boolean; size: number; pts: [number, number][] } | { id: number; erase: false; region: ImageEditMask };
const VW = 400;
const VH = 300;
const strokePath = (pts: [number, number][]) => (pts.length === 1 ? `M${pts[0][0]} ${pts[0][1]}l0.01 0` : pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join(''));
/** Bounding box of the painted strokes in percent */
function bounds(strokes: Stroke[]): ImageEditMask | null {
let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
for (const s of strokes) {
if (s.erase) continue;
if ('region' in s) {
const r = s.region;
x0 = Math.min(x0, r.x);
y0 = Math.min(y0, r.y);
x1 = Math.max(x1, r.x + r.w);
y1 = Math.max(y1, r.y + r.h);
continue;
}
for (const [x, y] of s.pts) {
const rx = (s.size / 2 / VW) * 100;
const ry = (s.size / 2 / VH) * 100;
x0 = Math.min(x0, (x / VW) * 100 - rx);
y0 = Math.min(y0, (y / VH) * 100 - ry);
x1 = Math.max(x1, (x / VW) * 100 + rx);
y1 = Math.max(y1, (y / VH) * 100 + ry);
}
}
if (!Number.isFinite(x0)) return null;
const c = (n: number) => Math.max(0, Math.min(100, n));
return { x: round(c(x0)), y: round(c(y0)), w: round(c(x1) - c(x0)), h: round(c(y1) - c(y0)), shape: 'ellipse' };
}
function Inpaint({ t, title, description, image, result, prompt = '', mask, regions = [], cost, note, onGenerate, onAction, className }: P) {
const uid = useId();
const seq = useRef(1);
const initial: Stroke[] = mask ? [{ id: 0, erase: false, region: mask }] : [];
const [strokes, setStrokes] = useSynced(initial);
const [tool, setTool] = useState<'brush' | 'eraser'>('brush');
const [size, setSize] = useState(36);
const [text, setText] = useState(prompt);
const [done, setDone] = useState<'shown' | 'kept' | 'discarded' | null>(null);
const [reveal, setReveal] = useState(false);
const [peek, setPeek] = useState(false);
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
const svg = useRef<SVGSVGElement>(null);
const drawing = useRef<number | null>(null);
const run = useRender();
const box = bounds(strokes);
const c = center(box);
const point = (e: React.PointerEvent): [number, number] => {
const r = svg.current!.getBoundingClientRect();
return [round(((e.clientX - r.left) / r.width) * VW, 1), round(((e.clientY - r.top) / r.height) * VH, 1)];
};
const down = (e: React.PointerEvent<SVGSVGElement>) => {
if (run.busy || e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
const id = seq.current++;
drawing.current = id;
if (done) (setDone(null), setReveal(false));
setStrokes((s) => [...s, { id, erase: tool === 'eraser', size, pts: [point(e)] }]);
};
const move = (e: React.PointerEvent<SVGSVGElement>) => {
if (e.pointerType === 'mouse') {
const r = svg.current!.getBoundingClientRect();
setCursor({ x: e.clientX - r.left, y: e.clientY - r.top });
}
const id = drawing.current;
if (id === null) return;
const p = point(e);
setStrokes((s) => s.map((x) => (x.id === id && 'pts' in x ? { ...x, pts: [...x.pts, p] } : x)));
};
const up = () => {
drawing.current = null;
};
const pickRegion = (r: ImageEditRegion) => {
if (done) (setDone(null), setReveal(false));
setStrokes((s) => [...s, { id: seq.current++, erase: false, region: r.mask }]);
};
const generate = () => {
if (!box || !text.trim() || run.busy) return;
onGenerate?.(text, box);
setDone(null);
setReveal(false);
run.start(() => {
setDone('shown');
requestAnimationFrame(() => requestAnimationFrame(() => setReveal(true)));
});
};
const showResult = done === 'shown' || done === 'kept';
const scale = svg.current ? svg.current.getBoundingClientRect().width / VW : 1;
return (
<Stage title={title} description={description} className={className}>
<Style />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[auto_minmax(0,1fr)_19rem] lg:items-start lg:gap-5">
{/* Tools: a rail on the desktop, a row on tablets and phones */}
<Reveal delay={40} className="lg:sticky lg:top-6">
<div role="group" aria-label={t.tools} className={cn(card, 'flex items-center gap-1 p-1.5 lg:flex-col')}>
{(['brush', 'eraser'] as const).map((k) => (
<button key={k} type="button" aria-pressed={tool === k} aria-label={t[k]} title={t[k]} onClick={() => setTool(k)} className={cn('grid size-11 place-items-center rounded-lg transition-colors', tool === k ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
<Icon name={k === 'brush' ? 'brush' : 'eraser'} size={18} />
</button>
))}
<span aria-hidden className="mx-1 h-6 w-px bg-border lg:mx-0 lg:my-1 lg:h-px lg:w-6" />
<label className="flex min-w-0 flex-1 items-center gap-2 px-1 lg:hidden">
<span className="sr-only">{t.size}</span>
<Icon name="circle" size={10} className="shrink-0 text-muted-foreground" />
<input type="range" min={12} max={80} value={size} onChange={(e) => setSize(Number(e.target.value))} className="h-11 w-full min-w-0 accent-[var(--primary)]" />
<Icon name="circle" size={18} className="shrink-0 text-muted-foreground" />
</label>
<button type="button" aria-label={t.undo} title={t.undo} disabled={!strokes.length || run.busy} onClick={() => setStrokes((s) => s.slice(0, -1))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40">
<Icon name="undo" size={18} />
</button>
<button type="button" aria-label={t.clear} title={t.clear} disabled={!strokes.length || run.busy} onClick={() => (setStrokes([]), setDone(null), setReveal(false))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40">
<Icon name="trash" size={18} />
</button>
</div>
</Reveal>
<Reveal effect="scale" delay={80} className="min-w-0">
<div className={cn(card, 'p-2')}>
<div className="relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2" aria-label={t.canvas} role="img">
<Layer>{image}</Layer>
{showResult && result && (
<Layer className={cn('aie-reveal z-[1]', peek && 'invisible opacity-0')} style={{ clipPath: reveal ? `circle(150% at ${c.x}% ${c.y}%)` : `circle(0% at ${c.x}% ${c.y}%)` }}>
{result}
</Layer>
)}
{/* The painted area, brush strokes with eraser strokes cut out */}
<svg
ref={svg}
viewBox={`0 0 ${VW} ${VH}`}
preserveAspectRatio="none"
aria-hidden
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onPointerLeave={() => setCursor(null)}
className={cn('absolute inset-0 z-[2] size-full touch-none transition-opacity duration-300', showResult ? 'pointer-events-none opacity-0' : 'cursor-none')}
>
<defs>
<mask id={`${uid}-m`} maskUnits="userSpaceOnUse" x="0" y="0" width={VW} height={VH}>
<rect width={VW} height={VH} fill="white" />
{strokes.map((s) => s.erase && 'pts' in s && <path key={s.id} d={strokePath(s.pts)} stroke="black" strokeWidth={s.size} strokeLinecap="round" strokeLinejoin="round" fill="none" />)}
</mask>
</defs>
<g mask={`url(#${uid}-m)`} opacity="0.55">
{strokes.map((s) =>
s.erase ? null : 'region' in s ? (
<ellipse key={s.id} cx={round(((s.region.x + s.region.w / 2) / 100) * VW)} cy={round(((s.region.y + s.region.h / 2) / 100) * VH)} rx={round((s.region.w / 200) * VW)} ry={round((s.region.h / 200) * VH)} fill="var(--primary)" />
) : (
<path key={s.id} d={strokePath(s.pts)} stroke="var(--primary)" strokeWidth={s.size} strokeLinecap="round" strokeLinejoin="round" fill="none" />
),
)}
</g>
</svg>
{cursor && !showResult && !run.busy && (
<span aria-hidden className="pointer-events-none absolute z-[3] rounded-full border-[1.5px] border-white shadow-[0_0_0_1px_rgb(0_0_0/0.5)]" style={{ left: cursor.x, top: cursor.y, width: round(size * scale), height: round(size * scale), transform: 'translate(-50%,-50%)' }} />
)}
{run.busy && <Rendering mask={box} progress={run.progress ?? undefined} />}
{!strokes.length && !showResult && (
<span className="pointer-events-none absolute inset-x-0 top-1/2 z-[3] mx-auto w-fit -translate-y-1/2 rounded-lg bg-[color-mix(in_oklab,black_58%,transparent)] px-3 py-2 text-[13px] font-medium text-white backdrop-blur-sm">
<Icon name="brush" size={14} className="mr-1.5 inline align-[-2px]" />
{t.paintHint}
</span>
)}
{showResult && !peek && <AiMark t={t} />}
{showResult && peek && <Tag>{t.original}</Tag>}
</div>
<div className="flex flex-wrap items-center gap-2 px-1 pb-0.5 pt-2.5">
<label className="hidden min-w-0 flex-1 items-center gap-2.5 lg:flex">
<span className="text-[12.5px] text-muted-foreground">{t.size}</span>
<input type="range" min={12} max={80} value={size} onChange={(e) => setSize(Number(e.target.value))} className="h-9 w-full max-w-[12rem] min-w-0 accent-[var(--primary)]" />
<span className="w-8 text-[12.5px] tabular-nums text-muted-foreground">{size}</span>
</label>
<p className="min-w-0 flex-1 text-[12.5px] text-muted-foreground lg:hidden" aria-live="polite">
{box ? t.marked : t.noMask}
</p>
{showResult && <HoldCompare t={t} on={setPeek} />}
</div>
</div>
</Reveal>
<Reveal delay={160} className="space-y-4 md:grid md:grid-cols-2 md:items-start md:gap-4 md:space-y-0 lg:sticky lg:top-6 lg:block lg:space-y-4">
{regions.length > 0 && (
<div className={cn(card, 'p-4')}>
<p className="text-[13px] font-medium text-foreground">{t.areas}</p>
<div className="mt-3 flex flex-wrap gap-2">
{regions.map((r) => (
<button key={r.id} type="button" disabled={run.busy} onClick={() => pickRegion(r)} className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50 sm:min-h-9">
<Icon name="target" size={14} className="text-muted-foreground" />
{r.label}
</button>
))}
</div>
</div>
)}
<form
className={cn(card, 'p-4')}
onSubmit={(e) => {
e.preventDefault();
generate();
}}
>
<label htmlFor={`${uid}-ask`} className="text-[13px] font-medium text-foreground">
{t.ask}
</label>
<textarea id={`${uid}-ask`} rows={3} value={text} onChange={(e) => setText(e.target.value)} placeholder={t.askPlaceholder} className="mt-2 block w-full resize-none rounded-lg border border-border bg-background px-3 py-2.5 text-[14px] leading-relaxed text-foreground placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]" />
<p className="mt-2 hidden items-center gap-1.5 text-[12.5px] text-muted-foreground lg:flex" aria-live="polite">
<span className={cn('size-1.5 rounded-full', box ? 'bg-primary' : 'bg-muted-foreground')} />
{box ? t.marked : t.noMask}
</p>
<button type="submit" disabled={!box || !text.trim() || run.busy} className={cn(primaryBtn, 'mt-3 w-full')}>
<Icon name={run.busy ? 'loader' : 'sparkle'} size={15} className={run.busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
{run.busy ? t.generating : t.generate}
{cost && !run.busy && <span className="rounded-md bg-[rgb(255_255_255/0.18)] px-1.5 py-px text-[11.5px] tabular-nums">{cost}</span>}
</button>
{showResult && (
<div className="aie-up mt-3 grid grid-cols-2 gap-2" aria-live="polite">
<button type="button" onClick={() => (setDone(null), setReveal(false), onAction?.('discard'))} className={quietBtn}>
<Icon name="undo" size={15} />
{t.discard}
</button>
<button type="button" disabled={done === 'kept'} onClick={() => (setDone('kept'), onAction?.('keep'))} className={cn(quietBtn, done === 'kept' && 'text-success-foreground opacity-100!')}>
<Icon name="check" size={15} />
{done === 'kept' ? t.kept : t.keep}
</button>
</div>
)}
</form>
</Reveal>
</div>
<Note>{note ?? t.honest}</Note>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* before-after */
/* ------------------------------------------------------------------ */
function BeforeAfter({ t, title, description, image, result, prompt, mask, model, note, onAction, className }: P) {
const locale = useLocale();
const [pos, setPos] = useState(55);
const [view, setView] = useState<'slider' | 'side'>('slider');
const [area, setArea] = useState(false);
const [decision, setDecision] = useState<'keep' | 'discard' | null>(null);
const frame = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const setFrom = (clientX: number) => {
const r = frame.current!.getBoundingClientRect();
setPos(round(Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)), 1));
};
const key = (e: React.KeyboardEvent) => {
const step = e.shiftKey ? 10 : 2;
const next = e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? pos - step : e.key === 'ArrowRight' || e.key === 'ArrowUp' ? pos + step : e.key === 'Home' ? 0 : e.key === 'End' ? 100 : e.key === 'PageDown' ? pos - 10 : e.key === 'PageUp' ? pos + 10 : null;
if (next === null) return;
e.preventDefault();
setPos(round(Math.max(0, Math.min(100, next)), 1));
};
const views = [
{ id: 'slider' as const, label: t.slider, icon: 'columns' as IconName },
{ id: 'side' as const, label: t.sideBySide, icon: 'layout' as IconName },
];
return (
<Stage title={title} description={description} className={className}>
<Style />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start lg:gap-6">
<Reveal effect="scale" delay={60} className="min-w-0">
<div className={cn(card, 'p-2')}>
{view === 'slider' ? (
<div
ref={frame}
className="relative isolate aspect-[4/3] touch-pan-y select-none overflow-hidden rounded-xl bg-surface-2"
onPointerDown={(e) => {
if (e.button !== 0) return;
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
setFrom(e.clientX);
}}
onPointerMove={(e) => dragging.current && setFrom(e.clientX)}
onPointerUp={() => (dragging.current = false)}
onPointerCancel={() => (dragging.current = false)}
>
<Layer>{image}</Layer>
<Layer className="z-[1]" style={{ clipPath: `inset(0 0 0 ${pos}%)` }}>
{result}
</Layer>
{area && mask && <MaskOutline mask={mask} />}
<Tag className={cn('transition-opacity duration-200', pos < 14 && 'invisible opacity-0')}>{t.before}</Tag>
<Tag side="right" className={cn('transition-opacity duration-200', pos > 86 && 'invisible opacity-0')}>{t.after}</Tag>
<AiMark t={t} className={cn('left-auto right-2.5 transition-opacity duration-200', pos > 70 && 'invisible opacity-0')} />
<div aria-hidden className="pointer-events-none absolute inset-y-0 z-[3] w-0.5 -translate-x-1/2 bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.25)]" style={{ left: `${pos}%` }} />
<button
type="button"
role="slider"
aria-label={t.divider}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(pos)}
aria-valuetext={`${formatPercent(Math.round(100 - pos), locale)} ${t.after}`}
onKeyDown={key}
className="absolute top-1/2 z-[4] grid size-11 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize place-items-center rounded-full bg-white text-[#111] shadow-[0_2px_10px_rgb(0_0_0/0.35)] focus-visible:outline-offset-4"
style={{ left: `${pos}%` }}
>
<Icon name="arrows-left-right" size={18} />
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{[
{ label: t.before, media: image, ai: false },
{ label: t.after, media: result, ai: true },
].map((s) => (
<figure key={s.label} className="aie-up relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
<Layer>{s.media}</Layer>
{area && mask && <MaskOutline mask={mask} />}
<figcaption>
<Tag>{s.label}</Tag>
</figcaption>
{s.ai && <AiMark t={t} />}
</figure>
))}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 px-1 pb-0.5 pt-2.5">
<div role="radiogroup" aria-label={t.view} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
{views.map((v) => (
<button
key={v.id}
type="button"
role="radio"
aria-checked={view === v.id}
tabIndex={view === v.id ? 0 : -1}
onClick={() => setView(v.id)}
onKeyDown={(e) => {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
e.preventDefault();
const next = view === 'slider' ? 'side' : 'slider';
setView(next);
(e.currentTarget.parentElement?.querySelector(`[data-v="${next}"]`) as HTMLElement | null)?.focus();
}}
data-v={v.id}
className={cn('inline-flex h-11 items-center gap-1.5 rounded-md px-3 text-[12.5px] font-medium transition-colors sm:h-8', view === v.id ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
>
<Icon name={v.icon} size={14} />
{v.label}
</button>
))}
</div>
{mask && (
<label className="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-2 text-[12.5px] font-medium text-muted-foreground hover:text-foreground sm:min-h-9">
<input type="checkbox" checked={area} onChange={(e) => setArea(e.target.checked)} className="size-4 accent-[var(--primary)]" />
{t.showArea}
</label>
)}
</div>
</div>
</Reveal>
<Reveal delay={160} className="space-y-4 lg:sticky lg:top-6">
<div className={cn(card, 'p-4')}>
<p className="text-[12px] font-medium text-subtle-foreground">{t.edit}</p>
{prompt && <p className="mt-1.5 text-pretty text-[14.5px] leading-relaxed text-foreground">{prompt}</p>}
{model && (
<dl className="mt-3 flex items-center justify-between gap-3 border-t border-border pt-3 text-[12.5px]">
<dt className="text-muted-foreground">{t.model}</dt>
<dd className="font-medium text-foreground">{model}</dd>
</dl>
)}
</div>
<div className={cn(card, 'p-4')}>
<p className="text-[13px] font-medium text-foreground">{t.decide}</p>
<p className="mt-1 text-[12.5px] leading-relaxed text-muted-foreground">{t.decideText}</p>
<div className="mt-3 grid grid-cols-2 gap-2">
<button type="button" aria-pressed={decision === 'discard'} onClick={() => (setDecision('discard'), setPos(100), onAction?.('discard'))} className={cn(quietBtn, decision === 'discard' && 'border-border-strong bg-accent')}>
<Icon name="undo" size={15} />
{t.discard}
</button>
<button type="button" aria-pressed={decision === 'keep'} onClick={() => (setDecision('keep'), setPos(0), onAction?.('keep'))} className={primaryBtn}>
<Icon name="check" size={15} />
{t.keep}
</button>
</div>
<p className="mt-2 min-h-5 text-[12.5px] font-medium" aria-live="polite">
{decision === 'keep' && <span className="aie-up inline-flex items-center gap-1.5 text-success-foreground"><Icon name="check-circle" size={14} />{t.kept}</span>}
{decision === 'discard' && <span className="aie-up inline-flex items-center gap-1.5 text-muted-foreground"><Icon name="undo" size={14} />{t.discarded}</span>}
</p>
</div>
</Reveal>
</div>
<Note>{note ?? t.honest}</Note>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* variants */
/* ------------------------------------------------------------------ */
function Variants({ t, title, description, image, mask, options = [], prompt, cost, note, onAction, className }: P) {
const uid = useId();
const [sel, setSel] = useState(1);
const [round_, setRound] = useState(0);
const [zoom, setZoom] = useState(false);
const [applied, setApplied] = useState<string | null>(null);
const run = useRender();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const all = [{ id: '__original', label: t.original, media: image }, ...Array.from({ length: Math.min(4, options.length) }, (_, i) => options[(i + round_) % options.length])];
const cur = all[Math.min(sel, all.length - 1)];
// Zoom on the area: scale the picture so the area fills the tile
const zoomStyle = (m?: ImageEditMask): React.CSSProperties | undefined => {
if (!zoom || !m) return undefined;
const s = round(Math.min(3, 70 / Math.max(m.w, m.h)), 2);
const c = center(m);
return { transform: `scale(${s})`, transformOrigin: `${c.x}% ${c.y}%` };
};
return (
<Stage title={title} description={description} className={className}>
<Style />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start lg:gap-6">
<Reveal effect="scale" delay={60} className="min-w-0">
<div className={cn(card, 'p-2')}>
<div className="relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
<Layer key={`${cur.id}-${round_}`} className={cn('transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', sel > 0 && 'aie-develop')} style={zoomStyle(mask)}>
{cur.media}
</Layer>
{mask && !zoom && <MaskOutline mask={mask} />}
{run.busy && <Rendering mask={zoom ? null : mask} progress={run.progress ?? undefined} />}
<Tag>{sel === 0 ? t.original : cur.label}</Tag>
{sel > 0 && <AiMark t={t} />}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 px-1 pb-0.5 pt-2.5">
{prompt && <p className="min-w-0 flex-1 basis-full text-[13px] leading-snug text-muted-foreground sm:basis-0">{prompt}</p>}
{mask && (
<button type="button" aria-pressed={zoom} onClick={() => setZoom((z) => !z)} className={cn(quietBtn, zoom && 'border-border-strong bg-accent')}>
<Icon name={zoom ? 'zoom-out' : 'zoom-in'} size={15} />
{t.zoom}
</button>
)}
</div>
</div>
</Reveal>
<Reveal delay={160} className="lg:sticky lg:top-6">
<div className={cn(card, 'p-4')}>
<p id={`${uid}-o`} className="text-[13px] font-medium text-foreground">{t.options}</p>
<div role="radiogroup" aria-labelledby={`${uid}-o`} className="mt-3 grid grid-cols-2 gap-x-2 gap-y-2.5 sm:grid-cols-5 lg:grid-cols-2">
{all.map((o, i) => {
const on = i === sel;
return (
<button
key={`${o.id}-${i === 0 ? 0 : round_}`}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={on}
aria-label={i === 0 ? t.keepOriginal : fill(t.option, { name: o.label })}
tabIndex={on ? 0 : -1}
disabled={run.busy}
onClick={() => (setSel(i), setApplied(null))}
onKeyDown={(e) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + all.length) % all.length;
setSel(n);
setApplied(null);
refs.current[n]?.focus();
}}
className={cn('aie-up group min-w-0 rounded-[12px] text-left', i === 0 && 'col-span-2 sm:col-span-1 lg:col-span-2')}
style={{ '--aie-d': `${i * 70}ms` } as React.CSSProperties}
>
<span className={cn('block rounded-[12px] p-[3px] transition-colors duration-200', on ? 'bg-primary' : 'bg-transparent group-hover:bg-border')}>
<span className={cn('relative block overflow-hidden rounded-[9px] bg-surface-2', i === 0 ? 'aspect-[2/1] sm:aspect-[4/3] lg:aspect-[2/1]' : 'aspect-[4/3]')}>
<Layer style={zoomStyle(mask)} className="transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none">
{o.media}
</Layer>
{run.busy && i > 0 && <Rendering />}
{on && !run.busy && (
<span className="absolute right-1.5 top-1.5 z-[3] grid size-5 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-md)]">
<Icon name="check" size={11} strokeWidth={2.6} />
</span>
)}
</span>
</span>
<span className={cn('mt-1 block truncate px-1 text-[11.5px]', on ? 'font-medium text-foreground' : 'text-muted-foreground')}>{o.label}</span>
</button>
);
})}
</div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-1">
<button type="button" disabled={run.busy || options.length < 2} onClick={() => run.start(() => (setRound((r) => r + 1), setSel(1), setApplied(null)))} className={quietBtn}>
<Icon name={run.busy ? 'loader' : 'shuffle'} size={15} className={run.busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
{run.busy ? t.generating : t.more}
{cost && !run.busy && <span className="text-[11.5px] tabular-nums text-muted-foreground">{cost}</span>}
</button>
<button type="button" disabled={run.busy || applied === cur.id} onClick={() => (setApplied(cur.id), onAction?.('apply', cur.id))} className={primaryBtn}>
<Icon name="check" size={15} />
{sel === 0 ? t.keepOriginal : t.apply}
</button>
</div>
<p className="mt-2 min-h-5 text-[12.5px] font-medium text-success-foreground" aria-live="polite">
{applied && (
<span className="aie-up inline-flex items-center gap-1.5">
<Icon name="check-circle" size={14} />
{t.applied}
</span>
)}
</p>
</div>
</Reveal>
</div>
<Note>{note ?? t.honest}</Note>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* history */
/* ------------------------------------------------------------------ */
function History({ t, title, description, steps: initial = [], note, onAction, className }: P) {
const [steps] = useSynced(initial);
const [head, setHead] = useState(Math.max(0, initial.length - 1));
const [view, setView] = useState(Math.max(0, initial.length - 1));
const [msg, setMsg] = useState('');
const [rail, fade] = useScrollFade<HTMLOListElement>();
const cur = steps[Math.min(view, steps.length - 1)];
const latest = steps.length - 1;
const label = (s: ImageEditStep, i: number) => (i === 0 ? t.original_ : t[s.tool === 'original' ? 'original_' : s.tool]);
return (
<Stage title={title} description={description} className={className}>
<Style />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start lg:gap-6">
<Reveal effect="scale" delay={60} className="min-w-0">
<div className={cn(card, 'p-2')}>
<div className="relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
<Layer key={view} className="aie-develop">
{cur?.media}
</Layer>
<Tag>{view === head ? t.current : fill(t.viewing, { n: view + 1 })}</Tag>
{view > 0 && <AiMark t={t} />}
</div>
<div className="flex flex-wrap items-center gap-2 px-1 pb-0.5 pt-2.5">
<p className="min-w-0 flex-1 text-[13px] text-foreground">
<span className="text-muted-foreground">{fill(t.step, { n: view + 1 })} · </span>
{cur?.prompt}
</p>
{view !== head && (
<>
<button type="button" onClick={() => (setView(head), setMsg(''))} className={quietBtn}>
{t.toCurrent}
</button>
<button type="button" onClick={() => (setHead(view), setMsg(fill(t.restored, { n: view + 1 })), onAction?.('restore', cur?.id))} className={primaryBtn}>
<Icon name="history" size={15} />
{t.restore}
</button>
</>
)}
</div>
<p className="min-h-0 px-1 text-[12.5px] leading-snug text-muted-foreground empty:hidden" aria-live="polite">
{msg}
</p>
</div>
</Reveal>
<Reveal delay={160} className="min-w-0 lg:sticky lg:top-6">
<div className={cn(card, 'p-4 max-lg:px-0')}>
<p className="text-[13px] font-medium text-foreground max-lg:px-4">{t.steps}</p>
{/* A rail of thumbnails on phones and tablets, a list with the line on the desktop */}
<ol ref={rail} tabIndex={-1} className={cn('kit-no-scrollbar relative mt-3 flex snap-x scroll-px-4 gap-3 overflow-x-auto px-4 pb-1 lg:block lg:space-y-0 lg:overflow-visible lg:px-0 lg:pb-0', fade, 'lg:[mask-image:none]')}>
{steps.map((s, i) => {
const dropped = i > head;
const on = i === view;
return (
<li key={s.id} className="relative w-[8.5rem] shrink-0 snap-start lg:flex lg:w-auto lg:gap-3 lg:pb-3 last:lg:pb-0">
{i < steps.length - 1 && <span aria-hidden className={cn('absolute left-[19px] top-10 bottom-0 hidden w-px lg:block', i < head ? 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--border))]' : 'bg-border')} />}
<span aria-hidden className={cn('relative z-[1] hidden size-10 shrink-0 place-items-center rounded-full border lg:grid', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'border-border bg-card text-muted-foreground', dropped && 'border-dashed')}>
<Icon name={TOOL_ICON[i === 0 ? 'original' : s.tool]} size={16} />
</span>
<button
type="button"
aria-current={on ? 'step' : undefined}
onClick={() => (setView(i), setMsg(''))}
className={cn('group block w-full min-w-0 rounded-xl p-1.5 text-left transition-colors lg:-mt-1 lg:flex-1', on ? 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)]' : 'hover:bg-accent', dropped && 'opacity-60')}
>
<span className="flex gap-2.5 max-lg:flex-col">
<span data-clip-ok className="relative block aspect-[4/3] w-full shrink-0 overflow-hidden rounded-md bg-surface-2 lg:w-16 [&>*]:size-full">{s.media}</span>
<span className="block min-w-0">
<span className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
<span className="font-medium text-foreground">{label(s, i)}</span>
{s.time && <span className="tabular-nums">· {s.time}</span>}
</span>
<span className="mt-0.5 line-clamp-2 text-[12.5px] leading-snug text-muted-foreground">{s.prompt}</span>
{dropped && <span className="mt-1 inline-block rounded-[5px] border border-dashed border-border-strong px-1.5 text-[11px] text-muted-foreground">{t.dropped}</span>}
{i === head && i !== latest && <span className="mt-1 inline-block rounded-[5px] bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-1.5 text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.current}</span>}
</span>
</span>
</button>
</li>
);
})}
</ol>
{head < latest && (
<div className="mt-3 max-lg:px-4">
<button type="button" onClick={() => (setHead(latest), setView(latest), setMsg(''))} className={cn(quietBtn, 'w-full')}>
<Icon name="redo" size={15} />
{t.redo}
</button>
</div>
)}
</div>
</Reveal>
</div>
<Note>{note ?? t.honest}</Note>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* style-controls */
/* ------------------------------------------------------------------ */
function filterFor(styles: ImageEditStyle[], values: Record<string, number>) {
const parts: string[] = [];
for (const s of styles) {
const v = ((values[s.id] ?? s.value) - 50) / 50; // -1 to 1
if (s.effect === 'warmth') parts.push(`sepia(${round(Math.max(0, v) * 0.35)}) hue-rotate(${round(Math.min(0, v) * -14)}deg)`);
if (s.effect === 'contrast') parts.push(`contrast(${round(1 + v * 0.25)})`);
if (s.effect === 'saturation') parts.push(`saturate(${round(1 + v * 0.6)})`);
if (s.effect === 'softness') parts.push(`blur(${round(Math.max(0, v) * 1.4)}px) brightness(${round(1 + Math.max(0, v) * 0.06)})`);
}
return parts.join(' ') || undefined;
}
function StyleControls({ t, title, description, image, result, styles: initial = [], presets = [], cost, note, onGenerate, prompt = '', className }: P) {
const locale = useLocale();
const uid = useId();
const [styles] = useSynced(initial);
const start = Object.fromEntries(initial.map((s) => [s.id, s.value]));
const [values, setValues] = useState<Record<string, number>>(start);
const [preset, setPreset] = useState<string | null>(null);
const [keep, setKeep] = useState(true);
const [peek, setPeek] = useState(false);
const [version, setVersion] = useState(0);
const [msg, setMsg] = useState('');
const run = useRender();
const presetRefs = useRef<(HTMLButtonElement | null)[]>([]);
const changed = styles.some((s) => (values[s.id] ?? s.value) !== s.value);
return (
<Stage title={title} description={description} className={className}>
<Style />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start lg:gap-6">
<Reveal effect="scale" delay={60} className="min-w-0 lg:sticky lg:top-6">
<div className={cn(card, 'p-2')}>
<div className="relative isolate aspect-[4/3] overflow-hidden rounded-xl bg-surface-2">
<Layer>{image}</Layer>
<Layer key={version} className={cn('z-[1] transition-[filter,opacity] duration-300 motion-reduce:transition-none', version > 0 && 'aie-develop', peek && 'invisible opacity-0')} style={{ filter: filterFor(styles, values) }}>
{result}
</Layer>
{run.busy && <Rendering progress={run.progress ?? undefined} />}
{peek ? <Tag>{t.original}</Tag> : <AiMark t={t} />}
</div>
<div className="flex flex-wrap items-center gap-2 px-1 pb-0.5 pt-2.5">
<p className="min-w-0 flex-1 text-[12.5px] leading-snug text-muted-foreground">{t.preview}</p>
<HoldCompare t={t} on={setPeek} />
</div>
</div>
</Reveal>
<Reveal delay={160}>
<form
className={cn(card, 'p-4')}
onSubmit={(e) => {
e.preventDefault();
if (run.busy) return;
onGenerate?.(prompt, null);
run.start(() => (setVersion((v) => v + 1), setMsg(t.done)));
}}
>
{presets.length > 0 && (
<>
<p id={`${uid}-p`} className="text-[13px] font-medium text-foreground">{t.presets}</p>
<div role="radiogroup" aria-labelledby={`${uid}-p`} className="mt-2.5 grid grid-cols-2 gap-2">
{presets.map((p, i) => {
const on = preset === p.id;
return (
<button
key={p.id}
ref={(el) => {
presetRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on || (preset === null && i === 0) ? 0 : -1}
onClick={() => (setPreset(p.id), setValues((v) => ({ ...v, ...p.values })), setMsg(''))}
onKeyDown={(e) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + presets.length) % presets.length;
setPreset(presets[n].id);
setValues((v) => ({ ...v, ...presets[n].values }));
presetRefs.current[n]?.focus();
}}
className={cn('inline-flex min-h-11 items-center justify-center rounded-lg border px-3 text-[13px] font-medium transition-colors sm:min-h-9', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground')}
>
{p.label}
</button>
);
})}
</div>
<span aria-hidden className="my-4 block h-px bg-border" />
</>
)}
<div className="space-y-4">
{styles.map((s) => {
const v = values[s.id] ?? s.value;
return (
<div key={s.id}>
<div className="flex items-baseline justify-between gap-3">
<label htmlFor={`${uid}-${s.id}`} className="text-[13px] font-medium text-foreground">
{s.label}
</label>
<output htmlFor={`${uid}-${s.id}`} className="text-[12.5px] tabular-nums text-muted-foreground">
{formatPercent(v, locale)}
</output>
</div>
<input
id={`${uid}-${s.id}`}
type="range"
min={0}
max={100}
value={v}
aria-valuetext={formatPercent(v, locale)}
onChange={(e) => (setValues((x) => ({ ...x, [s.id]: Number(e.target.value) })), setPreset(null), setMsg(''))}
className="h-11 w-full accent-[var(--primary)] sm:h-7"
/>
{(s.low || s.high) && (
<div aria-hidden className="flex justify-between gap-3 text-[11.5px] text-subtle-foreground">
<span>{s.low}</span>
<span className="text-right">{s.high}</span>
</div>
)}
</div>
);
})}
</div>
<label className="mt-4 flex cursor-pointer items-start gap-3 rounded-lg border border-border p-3">
<input type="checkbox" checked={keep} onChange={(e) => setKeep(e.target.checked)} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" />
<span>
<span className="block text-[13px] font-medium text-foreground">{t.seed}</span>
<span className="mt-0.5 block text-[12.5px] leading-snug text-muted-foreground">{t.seedText}</span>
</span>
</label>
<div className="mt-4 grid grid-cols-1 gap-2">
<button type="submit" disabled={run.busy} className={cn(primaryBtn, 'w-full')}>
<Icon name={run.busy ? 'loader' : 'sparkle'} size={15} className={run.busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
<span>{run.busy ? t.generating : t.generateWith}</span>
{cost && !run.busy && <span className="rounded-md bg-[rgb(255_255_255/0.18)] px-1.5 py-px text-[11.5px] tabular-nums">{cost}</span>}
</button>
<button type="button" disabled={!changed || run.busy} onClick={() => (setValues(start), setPreset(null), setMsg(''))} className={cn(quietBtn, 'w-full')}>
<Icon name="rotate-ccw" size={15} />
{t.reset}
</button>
</div>
<p className="mt-2 min-h-5 text-[12.5px] font-medium text-success-foreground" aria-live="polite">
{msg && (
<span className="aie-up inline-flex items-center gap-1.5">
<Icon name="check-circle" size={14} />
{msg}
</span>
)}
</p>
</form>
</Reveal>
</div>
<Note>{note ?? t.honest}</Note>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* An AI image editor: paint over an area and say what goes there
* (inpainting), compare before and after with a slider or side by side,
* choose one of several results for the area, step back through the edits
* and tune the look with sliders and presets. Every edited picture carries
* a mark that AI changed it, nothing is kept before you decide.
*/
export function AiImageEditor({ variant = 'inpaint', labels, ...props }: AiImageEditorProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
switch (variant) {
case 'before-after':
return <BeforeAfter {...p} />;
case 'variants':
return <Variants {...p} />;
case 'history':
return <History {...p} />;
case 'style-controls':
return <StyleControls {...p} />;
default:
return <Inpaint {...p} />;
}
}Häufige Fragen
Wie installiere ich KI-Bildbearbeitung?
Mit aiw add @ai-website/ai-image-editor. Der Befehl legt die Datei components/ui/ai-image-editor.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und motion. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat KI-Bildbearbeitung?
5 Varianten, Übermalen und ersetzen, Vorher und nachher, Ergebnis wählen, Bearbeitungsverlauf und Look einstellen. Du wählst sie mit der Prop variant, zum Beispiel variant="before-after".
Kann ich die Texte von KI-Bildbearbeitung übersetzen?
Ja. KI-Bildbearbeitung bringt 65 eigene Texte auf Deutsch und Englisch mit, etwa „Bild zum Bearbeiten“, „Werkzeuge“ und „Pinsel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent KI-Bildbearbeitung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini KI-Bildbearbeitung mit aiw_components und installieren sie mit aiw_add @ai-website/ai-image-editor. Nenn im Prompt die Variante, etwa variant="inpaint", dann schreibt der Assistent deine Inhalte direkt in die Props.