Files

1519 lines
74 KiB
Svelte
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// InpaintModal — [Mask] | [Output] tabs over one image space. Paint the
// region, type a PromptChain-style prompt, Apply renders in the background
// (auto-switching to Output, repeatable), Save finalizes the shown render
// into the output path with lineage.
import { untrack } from "svelte";
import { portal } from "../lib/portal.js";
import { recallModalMemory, storeModalMemory } from "../lib/modal-memory.js";
import { loadModalSetup, saveModalSetup } from "../lib/modal-setup.js";
import SettingsSlider from "./model/SettingsSlider.svelte";
import SavePathInput from "./SavePathInput.svelte";
import SearchableSelect from "./shared/SearchableSelect.svelte";
import "./sidebar/modal-shared.css";
let {
open,
sourceUrl = "",
width = 0,
height = 0,
filename = "",
caps = null,
prefillPrompt = "",
referencePrompt = "",
fetchApi = null,
apiURL = (p) => p,
onRun = null, // (options) => tracker
onSave = null, // (doneState, prefix) => entry | null
onSaved = null, // (entry) => void — viewer jumps to the result
initialMask = null, // canvas/ImageData to pre-paint (Edit handoff)
imageKey = "", // stable id of the image (per-image session memory)
onUseInEdit = null, // (doneState) => void — hand the render back to Edit as a layer
elevated = false, // raise z-index above the Edit modal
onUploadReference = null, // (File) => Promise<filename in input/>
onInstallPack = null, // (injectable) => Promise<boolean installed>
mountPromptEditor = null, // (container, initialValue, onChange) => Promise<EditorView>
regionPrompts = [], // [{name, text}] — the image's per-$block region prompts, for one-click prefill
forcedPrefill = null, // auto-resolved region prompt (the mask's figure) — wins over generic prefill
forcedPrefillLabel = "", // which figure forcedPrefill resolved to, for the note
movedContent = false, // the content was dragged off its scene position — regional resolves wrong
onCancel,
} = $props();
let activeTab = $state("mask");
let prompt = $state("");
let denoise = $state(0.5);
let sampler = $state("");
let scheduler = $state("");
let steps = $state(0); // pre-filled to the engine's recipe on open (engineRecipe); 0 = inherit (source)
let cfg = $state(0); // pre-filled to the engine's recipe on open; 0 = inherit (source)
// Mask shaping (Advanced): grow dilates the painted mask, feather softens its
// edge — both forwarded to PromptChain_MaskedDetail.
let grow = $state(0);
let feather = $state(24);
let maskAdvancedOpen = $state(false);
let promptEditorView = null; // CM6 EditorView once mounted; prompt mirrors its content
let savePrefix = $state("");
// Condition: "none" | "pulid" | "ipadapter". referenceImage = filename in input/.
let condition = $state("none");
let referenceImage = $state("");
let conditionWeight = $state(0.6); // identity (pulid) / style (ipadapter) strength → node `weight`
let ipaWeightType = $state("style transfer");
let conditionAdvancedOpen = $state(false);
let ipaStartAt = $state(0);
let ipaEndAt = $state(0.7);
let pulidFidelity = $state(4);
let pulidStartAt = $state(0);
let pulidEndAt = $state(1);
let installing = $state(false);
let installedOverride = $state({}); // condition -> true after an inline install
let refInputEl;
let needsReference = $derived(condition === "pulid" || condition === "ipadapter");
// Engine: "source" = surgery on the image's own embedded prompt (all
// classic behavior); a picked SDXL checkpoint hash = standalone
// MaskedDetail graph — works without render metadata and unlocks the
// SDXL-only conditions on any source architecture; a picked Qwen Edit
// model = instruction-driven masked edit (SetLatentNoiseMask pinning).
let engineSel = $state("source");
let engineEntry = $derived(caps?.engineModels?.find((m) => m.hash === engineSel) || null);
let engineKind = $derived(engineEntry
? (engineEntry.architecture === "qwen_edit" ? "qwen" : engineEntry.architecture === "flux" ? "flux1" : engineEntry.architecture === "krea2" ? "krea2" : "sdxl")
: "source");
// Realism mode: a krea2_turbo-only recipe (abliterated encoder + realism/bypass
// LoRAs). Inpaint keeps qwen_image_vae (seam-safe) and MaskedDetail's sampler.
let realism = $state(false);
// Realism assets present? (abliterated encoder + realism + projector LoRAs).
// Computed once — installed lists don't change without a restart. Hides the
// toggle until the Krea 2 Realism pack is installed (Settings → Install).
const realismAssetsInstalled = (() => {
try {
const LG = window.LiteGraph;
const clip = LG?.createNode?.("CLIPLoader")?.widgets?.find((w) => w.name === "clip_name")?.options?.values || [];
const loras = LG?.createNode?.("LoraLoaderModelOnly")?.widgets?.find((w) => w.name === "lora_name")?.options?.values || [];
return clip.some((o) => /qwen3.?vl.*4b.*abliterated/i.test(o))
&& loras.some((o) => /krea2-realism/i.test(o))
&& loras.some((o) => /krea2_turbo_projector_scale/i.test(o));
} catch { return false; }
})();
let realismAvailable = $derived(engineKind === "krea2" && /turbo/i.test(engineEntry?.filename || "") && realismAssetsInstalled);
let engineGroups = $derived({
sdxl: (caps?.engineModels || []).filter((m) => m.architecture === "sdxl"),
flux1: (caps?.engineModels || []).filter((m) => m.architecture === "flux"),
krea2: (caps?.engineModels || []).filter((m) => m.architecture === "krea2"),
qwen: (caps?.engineModels || []).filter((m) => m.architecture === "qwen_edit"),
});
// SearchableSelect feed — same rows/groups the native <select> carried.
let engineSelectGroups = $derived([
...(caps?.sourceUsable !== false
? [{ label: "", options: [{ value: "source", label: "Source model — this image's own workflow (default)" }] }]
: []),
...(engineGroups.sdxl.length
? [{ label: "SDXL — re-render the masked region", options: engineGroups.sdxl.map((m) => ({ value: m.hash, label: m.displayName })) }]
: []),
...(engineGroups.flux1.length
? [{ label: "FLUX.1 — re-render the masked region", options: engineGroups.flux1.map((m) => ({ value: m.hash, label: m.displayName })) }]
: []),
...(engineGroups.krea2.length
? [{ label: "Krea 2 — re-render the masked region", options: engineGroups.krea2.map((m) => ({ value: m.hash, label: m.displayName })) }]
: []),
...(engineGroups.qwen.length
? [{ label: "Qwen Edit — instruction edit (masked)", options: engineGroups.qwen.map((m) => ({ value: m.hash, label: m.displayName })) }]
: []),
]);
let recSampler = $derived(engineKind === "qwen" || engineKind === "flux1" || engineKind === "krea2"
? (engineEntry?.gen?.sampler || "euler")
: (engineEntry?.gen?.sampler || caps?.recommendedSampler));
let recScheduler = $derived(engineKind === "qwen" || engineKind === "flux1" || engineKind === "krea2"
? (engineEntry?.gen?.scheduler || "simple")
: (engineEntry?.gen?.scheduler || caps?.recommendedScheduler));
// Steps/CFG the modal pre-fills per engine, mirroring the inpaint builders'
// own defaults (RAW 30/3.5 · Turbo 8/1.0 · SDXL 25/5 · Qwen 40/4 · Flux 25/1)
// so the sliders show real values instead of a bare 0. The source model has
// no fixed recipe — 0 tells the builder to inherit the image's own steps/cfg.
function engineRecipe(entry) {
const kind = entry
? (entry.architecture === "qwen_edit" ? "qwen" : entry.architecture === "flux" ? "flux1" : entry.architecture === "krea2" ? "krea2" : "sdxl")
: "source";
const isRaw = kind === "krea2" && /raw/i.test(entry?.filename || "") && !/turbo/i.test(entry?.filename || "");
if (kind === "krea2") return { steps: isRaw ? 30 : 8, cfg: isRaw ? 3.5 : 1.0 };
if (kind === "sdxl") return { steps: entry?.gen?.steps || 25, cfg: entry?.gen?.cfg ?? 5 };
if (kind === "qwen") return { steps: entry?.gen?.steps || 40, cfg: entry?.gen?.cfg ?? 4 };
if (kind === "flux1") return { steps: entry?.gen?.steps || 25, cfg: 1.0 };
return { steps: 0, cfg: 0 };
}
let recSteps = $derived(engineRecipe(engineEntry).steps);
let recCfg = $derived(engineRecipe(engineEntry).cfg);
function conditionOk(key) {
// PuLID/IPAdapter patch an SDXL checkpoint UNET — the qwen/flux1/krea2
// builders have no splice point.
if (engineKind === "qwen" || engineKind === "flux1" || engineKind === "krea2") return false;
return engineKind === "sdxl" || !!caps?.conditions?.[key]?.ok;
}
// Inpaint mode, mirroring the upscale modal's rows. Source engine only — an
// engine model is a standalone re-render with no scene to resolve.
let mode = $state("regional");
let memoryPrompt = null; // per-image remembered prompt; honored only when its mode matches
const MODE_ROWS = [
{ key: "basic", label: "Basic", desc: "Re-render the masked region with one prompt.", needs: null },
{ key: "depth", label: "Depth", desc: "One prompt + a depth ControlNet so structure can't drift.", needs: "depth" },
{ key: "regional", label: "Regional", desc: "The scene masks resolve each figure's prompt automatically.", needs: "regional" },
{ key: "regional-depth", label: "Regional + depth", desc: "Per-figure prompts plus depth structure-lock.", needs: "both" },
];
let isRegionalMode = $derived(mode === "regional" || mode === "regional-depth");
let currentModeRow = $derived(MODE_ROWS.find((r) => r.key === mode) || MODE_ROWS[0]);
function pickModeKey(key) { const r = MODE_ROWS.find((x) => x.key === key); if (r) pick(r); }
let showModeRows = $derived(engineKind === "source" && (!!caps?.regionalAvailable || !!caps?.depthAvailable));
let isRegionalRow = (row) => row.needs === "regional" || row.needs === "both";
function modeOk(row) {
if (row.needs === "regional") return !!caps?.regionalAvailable;
if (row.needs === "depth") return !!caps?.depthAvailable;
if (row.needs === "both") return !!caps?.regionalAvailable && !!caps?.depthAvailable;
return true;
}
function modeReason(row) {
if (modeOk(row)) return "";
if (row.needs === "depth" || row.needs === "both") {
if (!caps?.depthAvailable) return "Needs the ControlNet preprocessor pack and an SDXL union ControlNet model.";
}
return "This image's workflow carries no 3D pose scene to resolve regions from.";
}
// Per-mode deterministic prefill (source engine). Regional modes seed the
// FULL $block source — for IN-PLACE content the masks resolve each figure by
// position; for MOVED content the backend reads the masks at the content's
// origin (cond_offset), so the same full source still follows the moved
// figure. basic/depth seed a freshly-isolated figure prompt or the flat global.
function basePrefillFor() {
if (engineKind === "source") {
if (caps?.sourceModelInfo?.architecture === "qwen_edit") return "";
if (isRegionalMode && caps?.regionalAvailable
&& typeof referencePrompt === "string" && referencePrompt.trim()) return referencePrompt;
if (typeof forcedPrefill === "string" && forcedPrefill.trim()) return forcedPrefill;
return prefillPrompt || "";
}
if (engineKind === "qwen") return "";
return prefillPrompt || "";
}
function prefillFor() {
// Memory seeds the editor, but never across a mode change — it's set in the
// open-effect only when the stored mode matches, and cleared on a flip.
if (typeof memoryPrompt === "string" && memoryPrompt.trim()) return memoryPrompt;
return basePrefillFor();
}
function pick(row) {
if (!modeOk(row)) return;
const wasRegional = isRegionalMode;
mode = row.key;
// Crossing the regional boundary swaps what the prompt MEANS (full $block
// source vs flat) — re-seed deterministically, like the upscale modal.
if (wasRegional !== isRegionalMode) {
memoryPrompt = null;
prompt = prefillFor();
}
}
function pickEngine(value) {
engineSel = value;
const entry = caps?.engineModels?.find((m) => m.hash === value) || null;
const kind = entry
? (entry.architecture === "qwen_edit" ? "qwen" : entry.architecture === "flux" ? "flux1" : entry.architecture === "krea2" ? "krea2" : "sdxl")
: "source";
if (entry) {
// The picked model's own gen-time sampler pick beats the source's.
const fluxLike = kind === "qwen" || kind === "flux1" || kind === "krea2";
sampler = entry.gen?.sampler || (fluxLike ? "euler" : sampler);
scheduler = entry.gen?.scheduler || (fluxLike ? "simple" : scheduler);
if (fluxLike && condition !== "none") condition = "none";
} else {
sampler = caps?.defaultSampler || "";
scheduler = caps?.defaultScheduler || "";
// Back on source: a condition the source architecture can't carry resets.
if (condition !== "none" && !caps?.conditions?.[condition]?.ok) condition = "none";
}
const r = engineRecipe(entry);
steps = r.steps; cfg = r.cfg;
// The mode rows only apply to the source engine; switching engine changes
// what the prompt MEANS, so re-seed deterministically (edits discarded).
memoryPrompt = null;
prompt = prefillFor();
}
// Per-condition default strength (matches the node defaults the graft used to
// hardcode) and the IPAdapter weight-type enum (ComfyUI_IPAdapter_plus).
const DEFAULT_WEIGHT = { pulid: 0.45, ipadapter: 0.7 };
const IPA_WEIGHT_TYPES = [
"linear", "ease in", "ease out", "ease in-out", "reverse in-out",
"weak input", "weak output", "weak middle", "strong middle",
"style transfer", "composition", "strong style transfer",
"style and composition", "style transfer precise", "composition precise",
];
let refThumbUrl = $derived(referenceImage
? apiURL(`/view?filename=${encodeURIComponent(referenceImage)}&subfolder=&type=input`)
: "");
let refEl; // the read-only reference textarea
// Copy from the reference box ourselves: a global ComfyUI keydown intercepts
// Ctrl+C in CAPTURE phase (node copy) and kills the textarea's native copy.
// A window capture listener fires before it; handle Ctrl+C when the ref box is
// focused and stop the event so ComfyUI never sees it.
$effect(() => {
if (!open) return;
const onKey = (e) => {
// Ctrl+Enter = Apply — and NEVER ComfyUI's Queue Prompt (its keybinding
// isn't reserved-by-text-input, so it fires even from the prompt editor
// and re-runs the user's whole workflow underneath the modal).
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
e.stopImmediatePropagation();
apply(); // guards itself: no-op while running or maskless
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === "c" || e.key === "C") && refEl && document.activeElement === refEl) {
const text = refEl.selectionStart !== refEl.selectionEnd
? refEl.value.slice(refEl.selectionStart, refEl.selectionEnd) : refEl.value;
navigator.clipboard?.writeText(text);
e.preventDefault();
e.stopImmediatePropagation();
}
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
});
let progress = $state(null);
let doneState = $state(null);
let saving = $state(false);
let errorMsg = $state("");
let tracker = null;
let unsub = null;
// Fire the open-time reset ONCE per open transition. The body is wrapped in
// untrack() so reading caps/prefillPrompt/condition here doesn't make them
// dependencies — otherwise any prop change while the modal is open (a late
// prefillPrompt, a Condition pick) re-runs the whole reset, calling
// clearMask()/hasMask=false and wiping the user's freshly painted mask.
let wasOpen = false;
$effect(() => {
if (open && caps && !wasOpen) {
wasOpen = true;
untrack(() => {
const initialCondition = caps.defaultCondition || "none";
activeTab = "mask";
// No usable source workflow → the first engine model is the only way
// to render; otherwise every open defaults back to the classic graft —
// unless this image has session memory (last applied engine wins when
// it's still available).
const mem = recallModalMemory("inpaint", imageKey);
const memEngineValid = mem?.engine === "source"
? caps.sourceUsable !== false
: !!mem?.engine && !!caps.engineModels?.some((m) => m.hash === mem.engine);
engineSel = memEngineValid ? mem.engine
: caps.sourceUsable === false ? (caps.engineModels?.[0]?.hash || "source") : "source";
// Mode: remembered when valid for this image, else regional when the
// scene supports it. memoryPrompt is honored only when the stored mode
// matches the resolved mode — a stale flat prompt can't shadow the
// regional full source (and old memory with no mode field falls through
// to the deterministic seed).
const onSource = engineSel === "source";
const defaultMode = (onSource && caps.regionalAvailable) ? "regional" : "basic";
const memModeOk = onSource && (
mem?.mode === "basic" ? true
: mem?.mode === "depth" ? caps.depthAvailable
: mem?.mode === "regional" ? caps.regionalAvailable
: mem?.mode === "regional-depth" ? (caps.regionalAvailable && caps.depthAvailable)
: false);
mode = memModeOk ? mem.mode : defaultMode;
memoryPrompt = (mem && mem.mode === mode && typeof mem.prompt === "string" && mem.prompt.trim())
? mem.prompt : null;
prompt = prefillFor();
denoise = caps.defaultDenoise ?? 0.5;
sampler = caps.defaultSampler || "";
scheduler = caps.defaultScheduler || "";
// A pre-selected engine seeds its model's own gen sampler, same as a
// manual pickEngine — caps.defaultSampler is the source's, not its.
const preEntry = caps.engineModels?.find((m) => m.hash === engineSel);
if (preEntry?.gen?.sampler) sampler = preEntry.gen.sampler;
if (preEntry?.gen?.scheduler) scheduler = preEntry.gen.scheduler;
const r0 = engineRecipe(preEntry);
steps = r0.steps; cfg = r0.cfg;
if (memEngineValid && typeof mem.denoise === "number") denoise = mem.denoise;
grow = 0;
feather = 24;
maskOpacity = 0.55;
maskAdvancedOpen = false;
savePrefix = caps.defaultSavePrefix || "inpaint/inpaint";
condition = initialCondition;
referenceImage = caps.conditions?.[initialCondition]?.reference || "";
conditionWeight = DEFAULT_WEIGHT[initialCondition] ?? 0.6;
ipaWeightType = "style transfer";
conditionAdvancedOpen = false;
ipaStartAt = 0; ipaEndAt = 0.7;
pulidFidelity = 4; pulidStartAt = 0; pulidEndAt = 1;
installing = false;
progress = null;
doneState = null;
saving = false;
errorMsg = "";
hasMask = false;
requestAnimationFrame(() => { resetView(); clearMask(); applyInitialMask(); });
});
} else if (!open) {
wasOpen = false;
}
});
// ── saved-setup restore (server sidecar, keyed by imageKey == displayedHash) ──
// Loaded async on open into savedSetup; applied ONLY on the user's chip click,
// so it stays out of the untrack() reset above and can't wipe a painted mask.
// The PuLID / Style-Reference cluster is the headline gap — the reset wipes it
// back to defaults every open.
let savedSetup = $state(null);
let promptRestoreNonce = $state(0); // bump to force the prompt editor to reseed
$effect(() => {
if (!open || !fetchApi || !imageKey) { savedSetup = null; return; }
let cancelled = false;
loadModalSetup(fetchApi, imageKey).then((doc) => {
if (cancelled || !doc) return;
const ip = doc.kinds?.inpaint;
const dimsOk = !doc.dims || (doc.dims.w === width && doc.dims.h === height);
savedSetup = ip && dimsOk ? ip : null;
});
return () => { cancelled = true; };
});
function applySavedSetup() {
const s = savedSetup;
if (!s) return;
// Engine first — pickEngine reseeds sampler/scheduler and drops conditions
// for flux-like engines (same ordering as the open-effect's memory restore).
if (typeof s.engine === "string") {
const ok = s.engine === "source" ? (caps?.sourceUsable !== false)
: !!caps?.engineModels?.some((m) => m.hash === s.engine);
if (ok) pickEngine(s.engine);
}
if (engineKind === "source" && typeof s.mode === "string") {
const modeOk = s.mode === "basic" ? true
: s.mode === "depth" ? caps?.depthAvailable
: s.mode === "regional" ? caps?.regionalAvailable
: s.mode === "regional-depth" ? (caps?.regionalAvailable && caps?.depthAvailable)
: false;
if (modeOk) mode = s.mode;
}
if (typeof s.denoise === "number") denoise = s.denoise;
if (typeof s.sampler === "string") sampler = s.sampler;
if (typeof s.scheduler === "string") scheduler = s.scheduler;
if (typeof s.grow === "number") grow = s.grow;
if (typeof s.feather === "number") feather = s.feather;
if (typeof s.maskOpacity === "number") maskOpacity = s.maskOpacity;
// Condition cluster — only when the resolved engine can carry it and the
// condition is actually installed (flux/qwen engines can't).
const fluxLike = engineKind === "flux1" || engineKind === "qwen" || engineKind === "krea2";
// conditionOk (not the raw caps flag) so an SDXL-engine pick re-enables the
// condition the same way the live dropdown does — else it's silently dropped.
if (typeof s.condition === "string" && !(fluxLike && s.condition !== "none")
&& (s.condition === "none" || conditionOk(s.condition))) {
condition = s.condition;
if (typeof s.referenceImage === "string") referenceImage = s.referenceImage;
if (typeof s.conditionWeight === "number") conditionWeight = s.conditionWeight;
if (typeof s.ipaWeightType === "string") ipaWeightType = s.ipaWeightType;
if (typeof s.ipaStartAt === "number") ipaStartAt = s.ipaStartAt;
if (typeof s.ipaEndAt === "number") ipaEndAt = s.ipaEndAt;
if (typeof s.pulidFidelity === "number") pulidFidelity = s.pulidFidelity;
if (typeof s.pulidStartAt === "number") pulidStartAt = s.pulidStartAt;
if (typeof s.pulidEndAt === "number") pulidEndAt = s.pulidEndAt;
}
// Prompt — reseed the rich editor via the remount nonce (a bare assignment
// doesn't repaint the mounted CM6 view); prefillFor() reads memoryPrompt.
if (typeof s.prompt === "string" && s.prompt.trim()) {
memoryPrompt = s.prompt;
prompt = s.prompt;
promptRestoreNonce++;
}
// Painted mask — the saved alpha plane, recolored to the painter's red
// (source-in uses only its alpha, matching applyInitialMask's contract).
if (s.hasMask && imageKey && maskCanvas) {
const img = new Image();
img.onload = () => {
const ctx = maskCanvas.getContext("2d");
const tmp = document.createElement("canvas");
tmp.width = width; tmp.height = height;
const tctx = tmp.getContext("2d");
tctx.drawImage(img, 0, 0, width, height);
tctx.globalCompositeOperation = "source-in";
tctx.fillStyle = "rgb(255, 60, 60)";
tctx.fillRect(0, 0, width, height);
ctx.clearRect(0, 0, width, height);
ctx.drawImage(tmp, 0, 0);
hasMask = true;
};
img.src = apiURL(`/promptchain/modal-setup/${imageKey}/inpaint__mask.png?t=${Date.now()}`);
}
savedSetup = null; // dismiss the chip once applied
}
let running = $derived(!!progress && progress.phase !== "done" && progress.phase !== "error" && progress.phase !== "cancelled");
let outputUrl = $derived((() => {
if (progress?.phase === "running" && progress.previewUrl) return progress.previewUrl;
if (doneState?.temp) {
const t = doneState.temp;
return apiURL(`/view?filename=${encodeURIComponent(t.filename)}&subfolder=${encodeURIComponent(t.subfolder || "")}&type=${t.type || "temp"}&rand=${doneState.rand}`);
}
return null;
})());
let progressPercent = $derived(
progress?.phase === "running" && progress.max ? Math.round((progress.value / progress.max) * 100) : null
);
// ── painter state ──────────────────────────────────────────────
let viewportEl;
let maskCanvas; // overlay at natural image size
let zoom = $state(1);
let panX = $state(0);
let panY = $state(0);
// Brush size persists across opens (the modal remounts each open, so plain
// $state would reset to the default every time).
const BRUSH_SIZE_KEY = "pcr.inpaint.brushSize";
function loadBrushSize() {
try {
const v = Number(localStorage.getItem(BRUSH_SIZE_KEY));
if (Number.isFinite(v) && v >= 4 && v <= 256) return v;
} catch { /* localStorage blocked */ }
return 48;
}
let brushSize = $state(loadBrushSize());
$effect(() => {
try { localStorage.setItem(BRUSH_SIZE_KEY, String(brushSize)); } catch { /* ignore */ }
});
let eraser = $state(false);
// Display-only opacity of the mask overlay — lets you see the image behind a
// brushed area. The stored canvas is painted solid; this is CSS opacity, so
// exportMask (which reads canvas pixels) is unaffected.
let maskOpacity = $state(0.55);
let hasMask = $state(false);
let painting = false;
let panning = false;
let lastPt = null;
// Brush-size ring that tracks the cursor over the mask. Coords are viewport-
// local px; the ring diameter is brushSize scaled by zoom (brushSize is the
// stroke diameter in image px).
let cursorX = $state(0);
let cursorY = $state(0);
let cursorOver = $state(false);
// Output-tab before/after: a screen-space vertical divider wipes the original
// (left of it) over the result (right). splitX is viewport-local px; the
// images behind it share the mask tab's pan/zoom so they stay registered.
let compareSplit = $state(false);
let splitX = $state(0);
let splitDragging = false;
function resetView() {
if (!viewportEl || !width || !height) { zoom = 1; panX = 0; panY = 0; return; }
const rect = viewportEl.getBoundingClientRect();
zoom = Math.min(rect.width / width, rect.height / height, 1) || 1;
panX = (rect.width - width * zoom) / 2;
panY = (rect.height - height * zoom) / 2;
}
function toggleCompare() {
if (!outputUrl || running) return;
compareSplit = !compareSplit;
// open the divider centered so both halves are visible
if (compareSplit) {
const rect = viewportEl?.getBoundingClientRect();
splitX = rect ? rect.width / 2 : 0;
}
}
function onSplitDown(e) {
e.stopPropagation(); // the viewport would otherwise start a pan
e.preventDefault();
splitDragging = true;
e.currentTarget.setPointerCapture(e.pointerId);
}
function onSplitMove(e) {
if (!splitDragging) return;
e.stopPropagation();
const rect = viewportEl.getBoundingClientRect();
splitX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
}
function onSplitUp(e) {
splitDragging = false;
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* already released */ }
}
function clearMask() {
const ctx = maskCanvas?.getContext("2d");
if (ctx) ctx.clearRect(0, 0, width, height);
hasMask = false;
}
// Pre-paint a handed-in mask (Edit → Inpaint). Drawn as the red paint so the
// existing painter/eraser/export all work on it.
function applyInitialMask() {
if (!initialMask || !maskCanvas) return;
const ctx = maskCanvas.getContext("2d");
const tmp = document.createElement("canvas");
tmp.width = width; tmp.height = height;
const tctx = tmp.getContext("2d");
if (initialMask instanceof ImageData) tctx.putImageData(initialMask, 0, 0);
else tctx.drawImage(initialMask, 0, 0);
// recolor any non-transparent mask pixels to the painter's red
tctx.globalCompositeOperation = "source-in";
tctx.fillStyle = "rgb(255, 60, 60)";
tctx.fillRect(0, 0, width, height);
ctx.drawImage(tmp, 0, 0);
hasMask = true;
}
function toImageCoords(e) {
const rect = viewportEl.getBoundingClientRect();
return {
x: (e.clientX - rect.left - panX) / zoom,
y: (e.clientY - rect.top - panY) / zoom,
};
}
function strokeTo(pt) {
const ctx = maskCanvas.getContext("2d");
ctx.globalCompositeOperation = eraser ? "destination-out" : "source-over";
ctx.strokeStyle = "rgb(255, 60, 60)";
ctx.fillStyle = "rgb(255, 60, 60)";
ctx.lineWidth = brushSize;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (lastPt) {
ctx.beginPath();
ctx.moveTo(lastPt.x, lastPt.y);
ctx.lineTo(pt.x, pt.y);
ctx.stroke();
} else {
ctx.beginPath();
ctx.arc(pt.x, pt.y, brushSize / 2, 0, Math.PI * 2);
ctx.fill();
}
lastPt = pt;
if (!eraser) hasMask = true;
}
function onPointerDown(e) {
if (e.button > 2) return;
// Output tab: any drag pans (nothing to paint there). Mask tab keeps
// left = paint, Shift/middle/right = pan.
if (activeTab === "output" || e.button === 1 || e.button === 2 || e.shiftKey) {
panning = true;
lastPt = { x: e.clientX, y: e.clientY };
e.preventDefault();
viewportEl.setPointerCapture(e.pointerId);
return;
}
if (activeTab !== "mask" || running || e.button !== 0) {
// TEMP (can't-paint-after-reopen diagnosis): log why a left-click didn't
// paint so a stuck activeTab/running state is caught, not inferred.
if (e.button === 0) console.warn("[PCR][ip] paint click ignored:", { activeTab, running, hasMask });
return;
}
// preventDefault or the browser starts a native image drag mid-stroke and
// the source image gets dragged out instead of painting the mask.
e.preventDefault();
painting = true;
lastPt = null;
strokeTo(toImageCoords(e));
viewportEl.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
const rect = viewportEl.getBoundingClientRect();
cursorX = e.clientX - rect.left;
cursorY = e.clientY - rect.top;
if (panning) {
panX += e.clientX - lastPt.x;
panY += e.clientY - lastPt.y;
lastPt = { x: e.clientX, y: e.clientY };
return;
}
if (painting) strokeTo(toImageCoords(e));
}
function onPointerUp() {
painting = false;
panning = false;
lastPt = null;
}
function onWheel(e) {
e.preventDefault();
const rect = viewportEl.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
const newZoom = Math.max(0.05, Math.min(12, zoom * factor));
panX = cx - ((cx - panX) / zoom) * newZoom;
panY = cy - ((cy - panY) / zoom) * newZoom;
zoom = newZoom;
}
// Bounding box of the painted pixels in image coords (same alpha threshold
// as exportMask) — the qwen engine crops around it client-side.
function maskBboxFromCanvas() {
const ctx = maskCanvas?.getContext("2d");
if (!ctx) return undefined;
const d = ctx.getImageData(0, 0, width, height).data;
let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (d[(y * width + x) * 4 + 3] > 16) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return undefined;
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
}
// Mask export: black canvas, painted pixels → white (binary by alpha).
async function exportMask() {
const out = document.createElement("canvas");
out.width = width;
out.height = height;
const ctx = out.getContext("2d");
ctx.drawImage(maskCanvas, 0, 0);
const img = ctx.getImageData(0, 0, width, height);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const on = d[i + 3] > 16 ? 255 : 0;
d[i] = d[i + 1] = d[i + 2] = on;
d[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
return new Promise((resolve) => out.toBlob(resolve, "image/png"));
}
// Model identity for the prompt editor's templates/autocomplete: the picked
// engine model, else the image's own source model. Read live at menu-open
// time, so an engine switch retargets the same mounted editor.
function editorModelInfo() {
if (engineEntry) return { hash: engineEntry.hash, architecture: engineEntry.architecture };
return caps?.sourceModelInfo || null;
}
// Editor remounts on this key so a regional/engine flip re-seeds the prompt
// (assignment alone won't repaint the mounted CM6 view). Basic↔Depth share a
// key — both flat — so toggling depth keeps the user's prompt edits.
let promptSeedKey = $derived(engineKind + ":" + (engineKind === "source" ? (isRegionalMode ? "regional" : "flat") : "engine"));
// Mounts the rich prompt editor (tag highlighting + autocomplete) into its
// container while the modal is open. {#if open}/{#key} unmount the container,
// so destroy() tears the editor down — every (re)mount seeds from prefillFor
// (mode-deterministic), with onChange mirroring its text into `prompt`.
function promptEditor(node) {
let disposed = false;
let view = null;
(async () => {
if (!mountPromptEditor) return;
const v = await mountPromptEditor(node, untrack(() => prefillFor()), (text) => { prompt = text; }, editorModelInfo);
if (disposed) { v?.destroy?.(); return; }
view = v;
promptEditorView = v;
})().catch((e) => console.error("[Inpaint] prompt editor mount failed", e));
return {
destroy() {
disposed = true;
view?.destroy?.();
if (promptEditorView === view) promptEditorView = null;
},
};
}
// One-click region prefill: replace the prompt with the picked $block's
// text (both the CM6 editor and the plain-textarea fallback mirror it).
function applyRegionPrompt(r) {
const text = (r?.text || "").trim();
if (!text) return;
prompt = text;
if (promptEditorView) {
promptEditorView.dispatch({
changes: { from: 0, to: promptEditorView.state.doc.length, insert: text },
});
}
}
// ── conditions ─────────────────────────────────────────────────
const PACK_FOR = { pulid: "PuLID", ipadapter: "StyleReference" };
function isInstalled(key) {
return !!installedOverride[key] || !!caps?.conditions?.[key]?.installed;
}
async function pickCondition(value) {
condition = value;
errorMsg = "";
if (value === "none") return;
conditionWeight = DEFAULT_WEIGHT[value] ?? 0.6; // reseed strength to the picked condition's default
const cap = caps?.conditions?.[value];
referenceImage = cap?.reference || referenceImage || "";
// Inline install: an SDXL-ok but not-yet-installed pack is offered for
// install on pick; cancel reverts to plain Inpaint. conditionOk (not
// cap.ok): an SDXL engine pick unlocks conditions on any source.
if (conditionOk(value) && !isInstalled(value) && onInstallPack) {
installing = true;
try {
const ok = await onInstallPack(PACK_FOR[value]);
if (ok) installedOverride = { ...installedOverride, [value]: true };
else condition = "none";
} catch (e) {
condition = "none";
errorMsg = `Couldn't set up ${value === "pulid" ? "PuLID" : "Style Reference"}: ${e?.message || e}`;
} finally {
installing = false;
}
}
}
async function chooseReference(e) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || !onUploadReference) return;
try {
const name = await onUploadReference(file);
if (name) referenceImage = name;
} catch {
errorMsg = "reference upload failed";
}
}
// ── actions ────────────────────────────────────────────────────
async function apply() {
if (!onRun || running || !hasMask) return;
if (condition !== "none" && !isInstalled(condition)) { errorMsg = "Install this condition's pack first."; return; }
const maskBlob = await exportMask();
// Snapshot the mask THIS run rendered with — "Add to Edit" clips the
// returned layer to it, so repainting the mask in here (growing it past
// the original Edit selection) isn't chopped off at the old boundary.
let appliedMask = null;
if (onUseInEdit) {
appliedMask = document.createElement("canvas");
appliedMask.width = width; appliedMask.height = height;
appliedMask.getContext("2d").drawImage(maskCanvas, 0, 0);
}
doneState = null;
errorMsg = "";
activeTab = "output";
tracker = onRun({
maskBlob, prompt, denoise, grow, feather, sampler: sampler || null, scheduler: scheduler || null,
steps: steps > 0 ? steps : undefined, cfg: cfg > 0 ? cfg : undefined,
// Source engine carries the mode so a Basic pick forces a flat encode
// even if the prompt still has $blocks; engine models ignore it. (Moved
// content rides regional + the ImageViewer-injected condOffset, which
// shifts the region masks to the content's origin.)
mode: engineKind === "source" ? mode : undefined,
engine: engineKind === "sdxl" ? "sdxl-ckpt"
: engineKind === "flux1" ? "flux1-unet"
: engineKind === "krea2" ? "krea2-unet"
: engineKind === "qwen" ? "qwen-edit" : "source",
engineModel: engineEntry ? { hash: engineEntry.hash, filename: engineEntry.filename } : undefined,
engineGen: engineEntry?.gen || undefined,
realism: realismAvailable ? realism : undefined,
// The qwen builder crops around the mask client-side — the bbox is
// known here (the painted canvas), not on the server.
maskBbox: engineKind === "qwen" ? maskBboxFromCanvas() : undefined,
// No picked reference → lock to the image being inpainted itself (the
// upscale modal's zero-config Character-lock anchor).
condition, referenceImage: condition === "none" ? null : (referenceImage || "__self__"),
conditionWeight, ipaWeightType, ipaStartAt, ipaEndAt,
pulidFidelity, pulidStartAt, pulidEndAt,
});
storeModalMemory("inpaint", imageKey, {
prompt,
mode: engineKind === "source" ? mode : undefined,
engine: engineKind === "source" ? "source" : engineSel,
denoise,
});
// Durable per-image twin (sidecar): dials (PuLID/Style-Ref cluster is the
// headline) + prompt + the painted mask as an ALPHA plane — the raw
// maskCanvas (opaque-red where painted, transparent elsewhere), NOT
// exportMask's white-on-black, so applySavedSetup's source-in recolor
// round-trips it. Fire-and-forget.
const maskPlaneBlob = await new Promise((res) => maskCanvas.toBlob(res, "image/png"));
saveModalSetup(fetchApi, imageKey, "inpaint", {
engine: engineSel,
mode: engineKind === "source" ? mode : undefined,
denoise, sampler, scheduler, grow, feather, maskOpacity,
condition, referenceImage,
conditionWeight, ipaWeightType, ipaStartAt, ipaEndAt,
pulidFidelity, pulidStartAt, pulidEndAt,
prompt, hasMask: true,
}, { w: width, h: height }, maskPlaneBlob ? { "inpaint__mask.png": maskPlaneBlob } : {});
unsub?.();
unsub = tracker.subscribe((state) => {
progress = state;
if (state.phase === "done") {
doneState = { ...state, appliedMask, rand: Math.random() };
progress = null;
} else if (state.phase === "cancelled") {
progress = null;
activeTab = "mask";
} else if (state.phase === "error") {
// Toasts render under the fullscreen viewer — surface the failure in
// the modal itself or a failed run reads as a silent flicker.
errorMsg = state.message || "inpaint failed";
progress = null;
activeTab = "mask";
}
});
}
function stopRun() {
tracker?.cancel?.();
}
async function save() {
if (!onSave || !doneState || saving) return;
saving = true;
try {
const entry = await onSave(doneState, savePrefix.trim());
if (entry) {
// The viewer jumps to the result — copy this session's settings to
// its hash so a re-inpaint of the result picks up where this left off.
if (entry.hash) {
storeModalMemory("inpaint", entry.hash, {
prompt,
mode: engineKind === "source" ? mode : undefined,
engine: engineKind === "source" ? "source" : engineSel,
denoise,
});
}
close();
onSaved?.(entry);
}
} finally {
saving = false;
}
}
function close() {
unsub?.();
unsub = null;
tracker = null;
onCancel?.();
}
function handleKeydown(e) {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
if (running) return; // Stop is the only way out mid-run
close();
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- No outside-click close: a text-selection drag that releases on the backdrop
must not nuke the modal. Close via the ✕, Cancel, or Esc. -->
<div use:portal class="pcr-modal-backdrop" style:z-index={elevated ? 10006 : null} onkeydown={handleKeydown}>
<div class="pcr-modal pcr-ip-modal" role="dialog" aria-modal="true">
<div class="pcr-modal-header">
<span class="pcr-modal-title">{running ? "Inpainting…" : "Inpaint"}{filename ? ` — ${filename}` : ""}{width && height ? ` · ${width}×${height}` : ""}</span>
{#if !running}
<button class="pcr-modal-close" onclick={close} aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
{/if}
</div>
<div class="pcr-modal-body pcr-ip-body">
<div class="pcr-ip-left">
<div class="pcr-ip-tabs">
<div class="pcr-ip-seg">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-ip-tab" class:active={activeTab === "mask"} onclick={() => { if (!running) activeTab = "mask"; }}>Mask</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-ip-tab" class:active={activeTab === "output"} onclick={() => { activeTab = "output"; }}>Output</div>
</div>
{#if activeTab === "mask"}
<label class="pcr-ip-tool pcr-ip-opacity">Opacity
<input type="range" min="0" max="1" step="0.05" bind:value={maskOpacity} />
</label>
<div class="pcr-ip-tools">
<label class="pcr-ip-tool">Brush
<input type="range" min="4" max="1024" step="4" bind:value={brushSize} />
</label>
<div class="pcr-ip-iconbtns">
<button class="pcr-ip-iconbtn" class:active={!eraser} title="Brush" aria-label="Brush" onclick={() => { eraser = false; }}>
<svg viewBox="0 0 24 24"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
</button>
<button class="pcr-ip-iconbtn" class:active={eraser} title="Eraser" aria-label="Eraser" onclick={() => { eraser = true; }}>
<svg viewBox="0 0 24 24"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>
</button>
</div>
<button class="pcr-ip-tool-btn" onclick={clearMask}>Clear</button>
<button class="pcr-ip-tool-btn" onclick={resetView}>Fit</button>
</div>
{:else}
<div class="pcr-ip-tools">
<button class="pcr-ip-tool-btn" onclick={resetView}>Fit</button>
<button class="pcr-ip-tool-btn" class:on={compareSplit}
disabled={!outputUrl || running} onclick={toggleCompare}
title="Drag the divider to wipe the original over the result">Compare</button>
</div>
{/if}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pcr-ip-viewport"
class:out-mode={activeTab === "output"}
bind:this={viewportEl}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
onpointerenter={() => { cursorOver = true; }}
onpointerleave={() => { cursorOver = false; }}
onwheel={onWheel}
oncontextmenu={(e) => e.preventDefault()}
ondragstart={(e) => e.preventDefault()}
onselectstart={(e) => e.preventDefault()}
>
<!-- The painter stage stays mounted on both tabs (display toggle, not
{#if}) — unmounting would recreate the canvas and lose the mask. -->
<div class="pcr-ip-stage" style="transform: translate({panX}px, {panY}px) scale({zoom});"
style:display={activeTab === "mask" ? null : "none"}>
<img src={sourceUrl} alt="" draggable="false" width={width} height={height} />
<canvas bind:this={maskCanvas} width={width} height={height} style:opacity={maskOpacity}></canvas>
</div>
{#if activeTab === "mask" && cursorOver}
<div class="pcr-ip-brush-ring" class:eraser
style="left:{cursorX}px; top:{cursorY}px; width:{brushSize * zoom}px; height:{brushSize * zoom}px;"></div>
{/if}
{#if activeTab === "output"}
{#if running && progress?.previewUrl}
<!-- The live sampler preview is the CROP being re-rendered, not
the full image — forcing it to doc dims (or applying the
mask tab's zoom/pan) blows a small region up to the whole
stage. Fit it centered; the doc-sized transform applies
only to the finished composite below. -->
<div class="pcr-ip-center">
<img class="pcr-ip-live-preview" src={progress.previewUrl} alt="" draggable="false" />
</div>
{:else if outputUrl}
<!-- Same stage transform as the mask tab — zoom/pan carry over
so you inspect the exact region you painted. -->
<div class="pcr-ip-stage" style="transform: translate({panX}px, {panY}px) scale({zoom});">
<img src={outputUrl} alt="" draggable="false" width={width} height={height} />
</div>
{#if compareSplit}
<!-- The original, clipped to the LEFT of the divider in screen
space (calc(100% - splitX) = the right inset). The inner
stage uses the identical transform, so it stays pixel-
aligned with the result behind it. -->
<div class="pcr-ip-split-before" style="clip-path: inset(0 calc(100% - {splitX}px) 0 0);">
<div class="pcr-ip-stage" style="transform: translate({panX}px, {panY}px) scale({zoom});">
<img src={sourceUrl} alt="" draggable="false" width={width} height={height} />
</div>
</div>
<div class="pcr-ip-split-label before">Before</div>
<div class="pcr-ip-split-label after">After</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-ip-split-divider" style="left: {splitX}px;"
onpointerdown={onSplitDown} onpointermove={onSplitMove}
onpointerup={onSplitUp} onpointercancel={onSplitUp}>
<div class="pcr-ip-split-knob">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9.5 8 5.5 12 9.5 16"/><polyline points="14.5 8 18.5 12 14.5 16"/>
</svg>
</div>
</div>
{/if}
{:else}
<div class="pcr-ip-center">
<div class="pcr-ip-waiting">{running ? "Waiting for the sampler…" : "No output yet — paint a mask and Apply."}</div>
</div>
{/if}
{#if running}
<div class="pcr-ip-progress">
<div class="pcr-ip-bar">
<div class="pcr-ip-bar-fill" class:indeterminate={progressPercent == null}
style={progressPercent != null ? `width: ${progressPercent}%` : ""}></div>
</div>
{#if progressPercent != null}
<span class="pcr-ip-bar-label">step {progress.value}/{progress.max}</span>
{/if}
</div>
{/if}
{/if}
</div>
</div>
<div class="pcr-ip-right">
{#if savedSetup && !running}
<button class="pcr-ip-restore-chip" onclick={applySavedSetup}
title="Re-apply the engine / reference / dials from your last inpaint of this image">↩ Restore last setup</button>
{/if}
{#if caps?.engineModels?.length}
<div class="pcr-mcard">
<div class="pcr-mcard-title">Engine</div>
<SearchableSelect
id="pcr-ip-engine"
value={engineSel}
groups={engineSelectGroups}
popupKey="inpaint-engine"
disabled={installing || running}
onpick={(v) => pickEngine(v)}
/>
{#if engineKind === "sdxl"}
<div class="pcr-ip-hint">renders the painted region with this checkpoint — no render metadata needed, and PuLID / Style Reference work on any source</div>
{:else if engineKind === "flux1"}
<div class="pcr-ip-hint">re-renders the painted region with this FLUX.1 model at the denoise below — describe the content, strong on faces and eyes</div>
{:else if engineKind === "krea2"}
<div class="pcr-ip-hint">re-renders the painted region with this Krea 2 model at the denoise below — describe the content, sharp detail and clean structure</div>
{:else if engineKind === "qwen"}
<div class="pcr-ip-hint">follows your instruction inside the mask only — unmasked pixels stay untouched; the region renders at full model resolution</div>
{/if}
{#if realismAvailable}
<label class="pcr-ip-hint" style="display:flex;align-items:center;gap:6px;cursor:pointer;margin-top:6px;">
<input type="checkbox" bind:checked={realism} />
Realism — abliterated encoder + realism/bypass LoRAs (NSFW-capable)
</label>
{/if}
</div>
{/if}
{#if caps?.conditions && engineKind !== "qwen" && engineKind !== "flux1" && engineKind !== "krea2"}
<!-- conditions patch an SDXL checkpoint UNET — the qwen/flux1/krea2
engines have no splice point, so the whole block disappears
rather than offering dead options -->
<div class="pcr-mcard">
<div class="pcr-mcard-title">Condition</div>
<select id="pcr-ip-cond" class="pcr-ip-select" value={condition}
onchange={(e) => pickCondition(e.currentTarget.value)} disabled={installing || running}>
<option value="none">Inpaint</option>
<option value="pulid" disabled={!conditionOk("pulid")} title={conditionOk("pulid") ? "" : caps.conditions.pulid?.reason || ""}>Inpaint + PuLID</option>
<option value="ipadapter" disabled={!conditionOk("ipadapter")} title={conditionOk("ipadapter") ? "" : caps.conditions.ipadapter?.reason || ""}>Inpaint + Style Reference</option>
</select>
{#if installing}
<div class="pcr-ip-hint">Installing pack…</div>
{:else if needsReference}
<div class="pcr-ip-ref">
<button class="pcr-ip-tool-btn" onclick={() => refInputEl?.click()}>Choose image</button>
{#if refThumbUrl}
<img class="pcr-ip-ref-thumb" src={refThumbUrl} alt="reference" />
{:else}
<img class="pcr-ip-ref-thumb" src={sourceUrl} alt="this image" />
<span class="pcr-ip-hint">this image (default)</span>
{/if}
<input type="file" accept="image/*" bind:this={refInputEl} onchange={chooseReference} style="display:none" />
</div>
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">Strength</span>
<SettingsSlider min={0} max={1.5} step={0.05} value={conditionWeight}
savedValue={DEFAULT_WEIGHT[condition] ?? 0.6} onChange={(v) => { conditionWeight = v; }} />
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-ip-adv-head" onclick={() => { conditionAdvancedOpen = !conditionAdvancedOpen; }}>
{conditionAdvancedOpen ? "▾" : "▸"} Advanced
</div>
{#if conditionAdvancedOpen}
{#if condition === "ipadapter"}
<label class="pcr-ip-label" for="pcr-ip-wtype">Weight type</label>
<select id="pcr-ip-wtype" class="pcr-ip-select" bind:value={ipaWeightType}>
{#each IPA_WEIGHT_TYPES as wt}
<option value={wt}>{wt}</option>
{/each}
</select>
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">Start</span>
<SettingsSlider min={0} max={1} step={0.01} value={ipaStartAt}
savedValue={0} onChange={(v) => { ipaStartAt = v; }} />
</div>
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">End</span>
<SettingsSlider min={0} max={1} step={0.01} value={ipaEndAt}
savedValue={0.7} onChange={(v) => { ipaEndAt = v; }} />
</div>
{:else}
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">Fidelity</span>
<SettingsSlider min={0} max={10} step={1} value={pulidFidelity}
savedValue={4} onChange={(v) => { pulidFidelity = v; }} />
</div>
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">Start</span>
<SettingsSlider min={0} max={1} step={0.01} value={pulidStartAt}
savedValue={0} onChange={(v) => { pulidStartAt = v; }} />
</div>
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">End</span>
<SettingsSlider min={0} max={1} step={0.01} value={pulidEndAt}
savedValue={1} onChange={(v) => { pulidEndAt = v; }} />
</div>
{/if}
{/if}
{/if}
</div>
{/if}
{#if showModeRows}
<div class="pcr-mcard">
<div class="pcr-mcard-title">Mode</div>
<select class="pcr-ip-select" data-mode-select value={mode}
onchange={(e) => pickModeKey(e.currentTarget.value)}>
{#each MODE_ROWS as row}
<option value={row.key} disabled={!modeOk(row)} title={modeReason(row)}>{row.label}</option>
{/each}
</select>
<div class="pcr-ip-hint">{modeOk(currentModeRow) ? currentModeRow.desc : modeReason(currentModeRow)}</div>
</div>
{/if}
<div class="pcr-mcard">
<div class="pcr-mcard-title">{engineKind === "qwen" ? "Instruction" : "Prompt"}</div>
{#if regionPrompts.length && engineKind !== "qwen" && !movedContent && (!showModeRows || !isRegionalMode)}
<!-- chips = quick single-figure loader (basic/depth modes); regional
modes resolve figures by mask so they need no chips -->
{#if forcedPrefill && forcedPrefillLabel && !showModeRows}
<span class="pcr-ip-region-auto">🧍 auto-loaded {forcedPrefillLabel}'s prompt — switch below or edit</span>
{/if}
<div class="pcr-ip-regionrow">
{#each regionPrompts.filter((r) => r.text) as r (r.name)}
<button class="pcr-ip-region-chip" class:active={r.name === forcedPrefillLabel}
onclick={() => applyRegionPrompt(r)}
title={r.text.slice(0, 400)}>🧍 {r.name}</button>
{/each}
</div>
{/if}
{#key promptSeedKey + ":" + promptRestoreNonce}
{#if mountPromptEditor}
<div class="pcr-ip-prompt pcr-ip-prompt-editor" use:promptEditor></div>
{:else}
<textarea
class="pcr-ip-prompt"
bind:value={prompt}
spellcheck="false"
placeholder={"what should appear in the painted region\n\nNegative Prompt:\noptional"}
></textarea>
{/if}
{/key}
{#if referencePrompt && engineKind !== "qwen"}
<!-- always shown (matches the upscale modal): the ORIGINAL full
source for reference, even after you edit the prompt above. -->
<span class="pcr-ip-label">Workflow prompt (reference)</span>
<textarea class="pcr-ip-refprompt" bind:this={refEl} readonly spellcheck="false">{referencePrompt}</textarea>
{/if}
</div>
<div class="pcr-mcard">
<div class="pcr-mcard-title">Settings</div>
{#if engineKind !== "qwen"}
<!-- qwen has no denoise knob: 1.0 is structural — the noise mask
does the confining, partial denoise just blends a shifted
re-render -->
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">Denoise</span>
<SettingsSlider min={0.05} max={1} step={0.01} value={denoise}
savedValue={caps?.defaultDenoise ?? 0.5} onChange={(v) => { denoise = v; }} />
</div>
{/if}
{#if caps?.samplerOptions?.length}
<div class="pcr-ip-combos">
<div class="pcr-ip-combo">
<label class="pcr-ip-label" for="pcr-ip-sampler">Sampler</label>
<select id="pcr-ip-sampler" class="pcr-ip-select" class:at-rec={sampler === recSampler} bind:value={sampler}>
{#each caps.samplerOptions as opt}
<option value={opt} style:color={opt === recSampler ? "#5ed357" : engineKind === "source" && caps.samplerAlternates?.includes(opt) ? "#5dcaff" : "#999"}>
{opt}{opt === recSampler ? " ●" : ""}
</option>
{/each}
</select>
</div>
<div class="pcr-ip-combo">
<label class="pcr-ip-label" for="pcr-ip-scheduler">Scheduler</label>
<select id="pcr-ip-scheduler" class="pcr-ip-select" class:at-rec={scheduler === recScheduler} bind:value={scheduler}>
{#each caps.schedulerOptions as opt}
<option value={opt} style:color={opt === recScheduler ? "#5ed357" : engineKind === "source" && caps.schedulerAlternates?.includes(opt) ? "#5dcaff" : "#999"}>
{opt}{opt === recScheduler ? " ●" : ""}
</option>
{/each}
</select>
</div>
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="pcr-ip-adv-head" onclick={() => { maskAdvancedOpen = !maskAdvancedOpen; }}>
{maskAdvancedOpen ? "▾" : "▸"} Advanced
</div>
{#if maskAdvancedOpen}
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">Grow</span>
<SettingsSlider min={0} max={64} step={1} value={grow}
savedValue={0} onChange={(v) => { grow = v; }} />
</div>
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">Feather</span>
<SettingsSlider min={0} max={128} step={1} value={feather}
savedValue={24} onChange={(v) => { feather = v; }} />
</div>
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">Steps</span>
<SettingsSlider min={0} max={60} step={1} value={steps}
savedValue={recSteps} onChange={(v) => { steps = v; }} />
</div>
<div class="pcr-ip-denoise">
<span class="pcr-ip-label">CFG</span>
<SettingsSlider min={0} max={10} step={0.1} value={cfg}
savedValue={recCfg} onChange={(v) => { cfg = v; }} />
</div>
<div style="opacity:0.55;font-size:11px;padding:2px 0 0 2px">Pre-filled with this engine's recipe (Turbo 8/1.0 · RAW 30/3.5 · SDXL 25/5). On the source model, 0 = inherit the image's own.</div>
{/if}
{#if !onUseInEdit}
<!-- Edit handoffs return the render as a layer — Edit's own Save persists. -->
<span class="pcr-ip-label">Save to</span>
<SavePathInput value={savePrefix} onChange={(v) => { savePrefix = v; }} {fetchApi} />
{/if}
</div>
</div>
</div>
{#if errorMsg}
<div class="pcr-ip-error">⚠ {errorMsg}</div>
{/if}
<div class="pcr-modal-footer">
{#if running}
<button class="pcr-modal-btn pcr-modal-btn-danger" onclick={stopRun}>Stop</button>
{:else}
<button class="pcr-modal-btn pcr-modal-btn-secondary" onclick={close}>Cancel</button>
<button class="pcr-modal-btn pcr-modal-btn-primary" onclick={apply}
disabled={!hasMask || installing}
title={!hasMask ? "Paint a mask first" : ""}>
{doneState ? "Re-Apply" : "Apply"}
</button>
{#if onUseInEdit}
<button class="pcr-modal-btn pcr-modal-btn-primary" onclick={() => onUseInEdit(doneState)} disabled={!doneState}>
Add to Edit
</button>
{:else}
<button class="pcr-modal-btn pcr-modal-btn-primary" onclick={save} disabled={!doneState || saving}>
{saving ? "Saving…" : "Save"}
</button>
{/if}
{/if}
</div>
</div>
</div>
{/if}
<style>
/* near-fullscreen, same as the Edit modal — painting surfaces get the
whole monitor */
.pcr-ip-modal {
width: 96vw; height: 94vh;
min-width: 900px; max-width: 96vw;
display: flex; flex-direction: column;
}
.pcr-ip-body { display: flex; gap: 16px; flex: 1; min-height: 0; }
.pcr-ip-left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pcr-ip-right { flex: 0 0 320px; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; padding-right: 4px; }
.pcr-ip-restore-chip {
align-self: flex-start; margin-bottom: 10px;
padding: 5px 11px; font-size: 12px; color: #d8c08a;
background: rgba(200, 89, 9, 0.12); border: 1px solid rgba(200, 89, 9, 0.5);
border-radius: 6px; cursor: pointer;
}
.pcr-ip-restore-chip:hover { color: #fff; background: rgba(200, 89, 9, 0.25); border-color: #c85909; }
.pcr-ip-tabs { display: flex; align-items: center; margin-bottom: 8px; }
/* Segmented control, same pattern as the upscale modal's .pcr-up-seg */
.pcr-ip-seg {
display: flex; border: 1px solid #3a3a3a; border-radius: 5px; overflow: hidden;
}
.pcr-ip-tab {
padding: 4px 18px; font-size: 12px; color: #aaa; cursor: pointer;
background: transparent;
}
.pcr-ip-tab + .pcr-ip-tab { border-left: 1px solid #3a3a3a; }
.pcr-ip-tab:hover:not(.active) { color: #ddd; }
.pcr-ip-tab.active { background: #c85909; color: #fff; }
.pcr-ip-tools { display: flex; align-items: center; gap: 8px; margin-left: auto; align-self: center; }
.pcr-ip-tool { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #888; }
.pcr-ip-tool input[type="range"] { width: 110px; accent-color: #c85909; }
/* Mask opacity — left-aligned right after the Mask/Output tabs (the brush
tools stay pushed to the right by .pcr-ip-tools margin-left:auto). */
.pcr-ip-opacity { margin-left: 14px; }
.pcr-ip-tool-btn {
padding: 3px 10px; font-size: 11.5px; color: #aaa;
background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 4px; cursor: pointer;
}
.pcr-ip-tool-btn:hover { color: #fff; border-color: #555; }
.pcr-ip-tool-btn.on { color: #fff; border-color: #c85909; background: rgba(200, 89, 9, 0.2); }
.pcr-ip-tool-btn:disabled { opacity: 0.4; cursor: default; }
.pcr-ip-tool-btn:disabled:hover { color: #aaa; border-color: #3a3a3a; }
/* Brush / eraser segmented icon pair — same icon-button look as the Edit modal. */
.pcr-ip-iconbtns {
display: flex; gap: 2px; padding: 1px;
background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 5px;
}
.pcr-ip-iconbtn {
width: 28px; height: 26px;
display: flex; align-items: center; justify-content: center;
background: transparent; border: none; border-radius: 4px;
color: #aaa; cursor: pointer;
}
.pcr-ip-iconbtn svg {
width: 16px; height: 16px; fill: none; stroke: currentColor;
stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
}
.pcr-ip-iconbtn:hover:not(.active) { color: #fff; background: #353535; }
.pcr-ip-iconbtn.active { background: #c85909; color: #fff; }
.pcr-ip-viewport {
position: relative; overflow: hidden;
flex: 1; min-height: 0; background: #141414;
border: 1px solid #3a3a3a; border-radius: 6px;
touch-action: none; cursor: crosshair;
}
.pcr-ip-viewport.out-mode { cursor: grab; }
.pcr-ip-viewport.out-mode:active { cursor: grabbing; }
.pcr-ip-stage { position: absolute; transform-origin: 0 0; }
.pcr-ip-stage img { display: block; user-select: none; pointer-events: none; }
.pcr-ip-stage canvas { position: absolute; inset: 0; pointer-events: none; }
/* Before/after split (Output tab). The wrapper fills the viewport so its
clip-path inset is screen-space; pointer-events:none lets a pan pass
through to the viewport — only the divider grabs the pointer. */
.pcr-ip-split-before { position: absolute; inset: 0; z-index: 4; pointer-events: none; }
.pcr-ip-split-divider {
position: absolute; top: 0; bottom: 0;
width: 18px; margin-left: -9px; /* wide invisible grab zone, line centered */
z-index: 7; cursor: ew-resize; touch-action: none;
}
.pcr-ip-split-divider::before {
content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
width: 2px; margin-left: -1px;
background: rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}
.pcr-ip-split-knob {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 30px; height: 30px; border-radius: 50%;
background: rgba(20, 20, 20, 0.85); border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
display: flex; align-items: center; justify-content: center; color: #fff;
}
.pcr-ip-split-knob svg { width: 18px; height: 18px; }
.pcr-ip-split-label {
position: absolute; top: 8px; z-index: 6;
padding: 2px 7px; font-size: 11px; font-weight: 600; letter-spacing: 0.3px;
color: #fff; background: rgba(20, 20, 20, 0.7); border-radius: 4px;
pointer-events: none;
}
.pcr-ip-split-label.before { left: 8px; }
.pcr-ip-split-label.after { right: 8px; }
/* Brush-size preview ring — follows the cursor, sized to the live brush.
Double outline (border + box-shadow) keeps it visible on any image. */
.pcr-ip-brush-ring {
position: absolute; z-index: 6;
border-radius: 50%; transform: translate(-50%, -50%);
pointer-events: none;
border: 1.5px solid rgba(255, 80, 80, 0.95);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55);
}
.pcr-ip-brush-ring.eraser {
border-style: dashed; border-color: rgba(255, 255, 255, 0.95);
}
.pcr-ip-center {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
.pcr-ip-live-preview {
max-width: 100%; max-height: 100%;
object-fit: contain;
}
.pcr-ip-waiting { font-size: 12.5px; color: #777; }
.pcr-ip-combos { display: flex; gap: 10px; }
.pcr-ip-combo { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.pcr-ip-select {
width: 100%; box-sizing: border-box;
padding: 5px 8px; font-size: 12px; color: #999;
background: #1c1c1c; border: 1px solid #3a3a3a; border-radius: 5px;
outline: none; cursor: pointer;
}
.pcr-ip-modes { display: flex; flex-direction: column; gap: 4px; margin: 2px 0 6px; }
.pcr-ip-mode {
display: flex; align-items: flex-start; gap: 8px; padding: 6px 8px;
background: #1c1c1c; border: 1px solid #3a3a3a; border-radius: 5px; cursor: pointer;
}
.pcr-ip-mode:hover:not(.disabled) { border-color: #555; }
.pcr-ip-mode.selected { border-color: rgba(94, 211, 87, 0.55); background: #1d241c; }
.pcr-ip-mode.disabled { opacity: 0.45; cursor: not-allowed; }
.pcr-ip-mode-radio {
flex: none; width: 14px; height: 14px; margin-top: 1px; border-radius: 50%;
border: 1px solid #555; display: flex; align-items: center; justify-content: center;
}
.pcr-ip-mode.selected .pcr-ip-mode-dot { width: 7px; height: 7px; border-radius: 50%; background: #5ed357; }
.pcr-ip-mode-label { font-size: 12px; color: #ddd; }
.pcr-ip-mode.selected .pcr-ip-mode-label { color: #5ed357; }
.pcr-ip-mode-desc { font-size: 11px; color: #888; margin-top: 1px; }
.pcr-ip-regionrow { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 6px; }
.pcr-ip-region-auto { display: block; font-size: 11px; color: #5ed357; margin: 2px 0; }
.pcr-ip-region-chip {
padding: 3px 10px; font-size: 11px; color: #bbb;
background: #242424; border: 1px solid #3a3a3a; border-radius: 11px;
cursor: pointer; font-family: inherit;
}
.pcr-ip-region-chip:hover { color: #fff; border-color: #c85909; }
.pcr-ip-region-chip.active { color: #5ed357; border-color: rgba(94, 211, 87, 0.45); }
/* matches the upscale modal's at-recommended green */
.pcr-ip-select.at-rec { color: #5ed357; border-color: rgba(94, 211, 87, 0.45); }
.pcr-ip-progress {
position: absolute; left: 16px; right: 16px; bottom: 12px;
display: flex; align-items: center; gap: 10px;
}
.pcr-ip-bar {
flex: 1; height: 6px; border-radius: 3px;
background: rgba(0, 0, 0, 0.55); border: 1px solid #3a3a3a; overflow: hidden;
}
.pcr-ip-bar-fill { height: 100%; background: #c85909; transition: width 0.2s ease; }
.pcr-ip-bar-fill.indeterminate { width: 35%; animation: pcr-ip-slide 1.2s ease-in-out infinite alternate; }
@keyframes pcr-ip-slide { from { margin-left: 0; } to { margin-left: 65%; } }
.pcr-ip-bar-label { font-size: 10.5px; color: #bbb; }
.pcr-ip-label { font-size: 11px; color: #888; margin-top: 6px; }
.pcr-ip-adv-head {
margin-top: 8px; font-size: 11px; color: #888;
cursor: pointer; user-select: none;
}
.pcr-ip-adv-head:hover { color: #ccc; }
.pcr-ip-ref { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.pcr-ip-ref-thumb {
width: 40px; height: 40px; object-fit: cover;
border: 1px solid #3a3a3a; border-radius: 4px; background: #141414;
}
.pcr-ip-hint { font-size: 11px; color: #777; }
/* Fixed heights now that each lives inside a card (the old flex-grow filled
the whole right panel; inside a card it can't, so the panel scrolls). */
.pcr-ip-prompt {
min-height: 180px; resize: vertical;
padding: 8px 10px; font-size: 12.5px; line-height: 1.45; color: #ddd;
background: #1c1c1c; border: 1px solid #3a3a3a; border-radius: 5px;
outline: none; font-family: inherit;
}
.pcr-ip-prompt:focus { border-color: #c85909; }
/* CM6 editor host — clear the textarea padding/resize (CM scrolls itself)
and let the editor fill the box; promptTheme paints its own surface. */
.pcr-ip-prompt-editor {
height: 200px; padding: 0; resize: none; overflow: hidden;
display: flex; flex-direction: column;
}
.pcr-ip-prompt-editor:focus-within { border-color: #c85909; }
.pcr-ip-prompt-editor :global(.cm-editor) { flex: 1; min-height: 0; height: 100%; }
.pcr-ip-prompt-editor :global(.cm-scroller) { overflow: auto; }
/* Read-only mirror of the source workflow's prompt — reference while editing. */
.pcr-ip-refprompt {
height: 110px; resize: vertical;
padding: 7px 10px; font-size: 11.5px; line-height: 1.4; color: #9a9a9a;
background: #161616; border: 1px solid #313131; border-radius: 5px;
outline: none; font-family: inherit; cursor: text; user-select: text;
}
.pcr-ip-denoise { display: flex; align-items: center; gap: 10px; }
.pcr-ip-denoise .pcr-ip-label { flex: none; width: 52px; margin-top: 0; }
.pcr-modal-btn:disabled { opacity: 0.45; cursor: default; }
.pcr-ip-error {
margin: 0 2px 2px; padding: 7px 12px;
font-size: 12px; color: #ff8a80; line-height: 1.4;
background: rgba(229, 57, 53, 0.12); border: 1px solid rgba(229, 57, 53, 0.4);
border-radius: 5px; word-break: break-word;
}
</style>