Zum Inhalt springen
UI Kit
Accent

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.

5 VariantsEine von 5 Components in KI-Schreiben und Erstellen

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

Übermalen und ersetzenNeu in 1.2

Pinsel und Radierer auf dem Bild, fertige Bereiche für die Tastatur, ein Prompt für das, was dort hin soll. Das Ergebnis wächst aus dem markierten Bereich und bleibt nur, wenn du es behältst.

Vorher und nachherNeu in 1.2

Ein Regler, der mit Zeiger und Pfeiltasten läuft, oder beide Bilder nebeneinander. Der bearbeitete Bereich lässt sich umranden, Behalten oder Verwerfen entscheidest du.

Ergebnis wählenNeu in 1.2

Vier Ergebnisse für denselben Bereich neben dem Original, ein Zoom auf den Bereich und neue Ergebnisse auf Wunsch. Die Pfeiltasten gehen durch die Auswahl.

BearbeitungsverlaufNeu in 1.2

Jede Bearbeitung als Schritt mit Werkzeug, Prompt und Uhrzeit. Jeder Schritt lässt sich ansehen und wiederherstellen, spätere werden beiseitegelegt statt gelöscht. Am Telefon eine Leiste mit Vorschaubildern.

Look einstellenNeu in 1.2

Looks als Vorgaben, Regler für Wärme, Kontrast, Farbe und Weichheit mit grober Live-Vorschau, der Bildaufbau bleibt auf Wunsch erhalten. Gedrückt halten zeigt das Original.

Details

Was KI-Bildbearbeitung mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle AiImageEditorProps aus components/ui/ai-image-editor.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AiImageEditorVariant

    inpaintbefore-aftervariantshistorystyle-controls

  • titleoptional
    string
  • descriptionoptional
    string
  • imageoptional
    React.ReactNode

    The image before the edit

  • resultoptional
    React.ReactNode

    The image after the edit

  • promptoptional
    string

    The edit in words

  • maskoptional
    ImageEditMask

    The edited area

  • regionsoptional
    ImageEditRegion[]

    inpaint: areas to select without drawing

  • optionsoptional
    ImageEditOption[]

    variants: results for the area to choose from

  • stepsoptional
    ImageEditStep[]

    history: the edits so far, the first is the original

  • stylesoptional
    ImageEditStyle[]

    style-controls

  • presetsoptional
    ImageEditPreset[]
  • modeloptional
    string

    e.g. "Atlas Image 3"

  • costoptional
    string

    Credits one run costs, shown on the button

  • noteoptional
    string

    Line under the editor, e.g. on usage rights

  • onGenerateoptional
    (prompt: string, mask: ImageEditMask | null) => void
  • onActionoptional
    (action: 'keep' | 'discard' | 'apply' | 'restore', id?: string) => void

    Keep, 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
canvasEnglisch Image to editDeutsch Bild zum Bearbeiten
toolsEnglisch ToolsDeutsch Werkzeuge
brushEnglisch BrushDeutsch Pinsel
eraserEnglisch EraserDeutsch Radierer
sizeEnglisch Brush sizeDeutsch Pinselgröße
undoEnglisch Undo strokeDeutsch Strich zurücknehmen
clearEnglisch Clear areaDeutsch Bereich leeren
areasEnglisch Select an areaDeutsch Bereich wählen
areaOnEnglisch {name} selectedDeutsch {name} gewählt
paintHintEnglisch Paint over the part you want to changeDeutsch Male über den Teil, der sich ändern soll
markedEnglisch Area markedDeutsch Bereich markiert
noMaskEnglisch Mark an area firstDeutsch Markier zuerst einen Bereich
askEnglisch What should go there?Deutsch Was soll dort hin?
askPlaceholderEnglisch A tall plant in a stone potDeutsch Eine hohe Pflanze im Steintopf
generateEnglisch GenerateDeutsch Erzeugen
generatingEnglisch GeneratingDeutsch Wird erzeugt
keepEnglisch KeepDeutsch Behalten
keptEnglisch KeptDeutsch Behalten
discardEnglisch DiscardDeutsch Verwerfen
discardedEnglisch Back to the originalDeutsch Zurück zum Original
compareEnglisch Hold to see the originalDeutsch Gedrückt halten für das Original
compareShortEnglisch OriginalDeutsch Original
aiEditedEnglisch Edited with AIDeutsch Mit KI bearbeitet
beforeEnglisch BeforeDeutsch Vorher
afterEnglisch AfterDeutsch Nachher
dividerEnglisch Compare before and afterDeutsch Vorher und nachher vergleichen
sliderEnglisch SliderDeutsch Regler
sideBySideEnglisch Side by sideDeutsch Nebeneinander
viewEnglisch ViewDeutsch Ansicht
showAreaEnglisch Show the edited areaDeutsch Bearbeiteten Bereich zeigen
editEnglisch The editDeutsch Die Änderung
modelEnglisch ModelDeutsch Modell
decideEnglisch Your decisionDeutsch Deine Entscheidung
decideTextEnglisch Nothing changes in your files until you keep the result.Deutsch An deinen Dateien ändert sich erst etwas, wenn du das Ergebnis behältst.
optionsEnglisch Results for this areaDeutsch Ergebnisse für diesen Bereich
optionEnglisch Use {name}Deutsch {name} verwenden
originalEnglisch OriginalDeutsch Original
keepOriginalEnglisch Keep the originalDeutsch Original behalten
moreEnglisch New resultsDeutsch Neue Ergebnisse
applyEnglisch Use this resultDeutsch Dieses Ergebnis verwenden
appliedEnglisch Result in the imageDeutsch Ergebnis im Bild
zoomEnglisch Show only the areaDeutsch Nur den Bereich zeigen
stepsEnglisch EditsDeutsch Bearbeitungen
stepEnglisch Step {n}Deutsch Schritt {n}
currentEnglisch CurrentDeutsch Aktuell
viewingEnglisch Viewing step {n}Deutsch Ansicht von Schritt {n}
restoreEnglisch Restore this stepDeutsch Diesen Schritt wiederherstellen
restoredEnglisch 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.
toCurrentEnglisch Back to the latestDeutsch Zur neuesten Fassung
droppedEnglisch Set asideDeutsch Beiseitegelegt
redoEnglisch Bring backDeutsch Zurückholen
original_Englisch OriginalDeutsch Original
eraseEnglisch RemovedDeutsch Entfernt
fillEnglisch Filled inDeutsch Ergänzt
expandEnglisch ExtendedDeutsch Erweitert
styleEnglisch StyleDeutsch Stil
upscaleEnglisch UpscaledDeutsch Vergrößert
presetsEnglisch LooksDeutsch Looks
resetEnglisch ResetDeutsch Zurücksetzen
previewEnglisch Rough preview. The real result comes when you generate.Deutsch Grobe Vorschau. Das echte Ergebnis entsteht beim Erzeugen.
seedEnglisch Keep the compositionDeutsch Bildaufbau beibehalten
seedTextEnglisch Same layout and objects, only the look changes.Deutsch Gleicher Aufbau und gleiche Dinge, nur der Look ändert sich.
generateWithEnglisch Generate with these settingsDeutsch Mit diesen Werten erzeugen
doneEnglisch New version readyDeutsch Neue Fassung fertig
honestEnglisch 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
components/ui/ai-image-editor.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert