document.addEventListener("DOMContentLoaded", () => {
const state = {
mode: "edit",
info: null,
capabilities: [],
presets: [],
sourceFile: null,
referenceFile: null,
backgroundFile: null,
batchFiles: [],
sourceDataUrl: "",
resultDataUrl: "",
isBusy: false,
compareMode: true,
fullscreen: false,
history: [],
historyIndex: -1,
storageKey: "imageeditter_history_v2",
progressTimer: null,
stageScale: 1,
stageOffsetX: 0,
stageOffsetY: 0,
stageDragging: false,
stageDragOrigin: null,
mask: {
tool: "brush",
size: 24,
drawing: false,
hasData: false,
lastPoint: null,
enabled: false,
},
};
const modeDescriptions = {
edit: "Prompt-driven image editing with CV planning and optional diffusion harmonization.",
generate: "Generate a fresh local scene or stylized image from text.",
inpaint: "Paint a mask over the source and repair or replace the selected region.",
style: "Transfer the palette and texture mood from a reference image.",
background: "Replace or redesign the background using text or a background image.",
upscale: "Scale up the source with local super-resolution style detail recovery.",
batch: "Apply the same edit pipeline across multiple images in one run.",
};
const progressMessages = {
edit: ["Parsing prompt...", "Planning operations...", "Running local stack...", "Refining output..."],
generate: ["Building base scene...", "Expanding prompt...", "Rendering locally...", "Finishing highlights..."],
inpaint: ["Reading mask...", "Cleaning masked region...", "Repairing pixels...", "Polishing result..."],
style: ["Reading reference style...", "Matching colors...", "Transferring texture cues...", "Finalizing output..."],
background: ["Segmenting subject...", "Designing backdrop...", "Blending edges...", "Refining scene..."],
upscale: ["Upscaling image...", "Injecting detail...", "Sharpening output..."],
batch: ["Preparing batch queue...", "Applying edits...", "Collecting results..."],
};
const dom = {
statusDot: document.getElementById("status-dot"),
statusText: document.getElementById("status-text"),
capabilityCount: document.getElementById("capability-count"),
presetCount: document.getElementById("preset-count"),
promptHint: document.getElementById("prompt-hint-text"),
modeTabs: [...document.querySelectorAll(".mode-tab")],
modeBlurb: document.getElementById("mode-blurb"),
modeCopy: document.getElementById("mode-copy"),
sourceDrop: document.getElementById("source-drop"),
referenceDrop: document.getElementById("reference-drop"),
backgroundDrop: document.getElementById("background-drop"),
batchDrop: document.getElementById("batch-drop"),
sourceInput: document.getElementById("source-input"),
referenceInput: document.getElementById("reference-input"),
backgroundInput: document.getElementById("background-input"),
batchInput: document.getElementById("batch-input"),
sourceMeta: document.getElementById("source-meta"),
referenceMeta: document.getElementById("reference-meta"),
backgroundMeta: document.getElementById("background-meta"),
batchMeta: document.getElementById("batch-meta"),
sourcePreview: document.getElementById("source-preview"),
resultPreview: document.getElementById("result-preview"),
compareOverlay: document.getElementById("compare-overlay"),
compareWrap: document.getElementById("compare-wrap"),
compareSlider: document.getElementById("compare-slider"),
stageFrame: document.getElementById("stage-frame"),
stageMedia: document.getElementById("stage-media"),
stageEmpty: document.getElementById("stage-empty"),
maskCanvas: document.getElementById("mask-canvas"),
maskPanel: document.getElementById("mask-panel"),
maskBrush: document.getElementById("mask-brush"),
maskErase: document.getElementById("mask-erase"),
maskMagic: document.getElementById("mask-magic"),
maskFill: document.getElementById("mask-fill"),
maskClear: document.getElementById("mask-clear"),
maskSize: document.getElementById("mask-size"),
maskSizeValue: document.getElementById("mask-size-value"),
btnClearAll: document.getElementById("btn-clear-all"),
btnViewToggle: document.getElementById("btn-view-toggle"),
btnDownload: document.getElementById("btn-download"),
btnFullscreen: document.getElementById("btn-fullscreen"),
promptInput: document.getElementById("prompt-input"),
runButton: document.getElementById("run-button"),
btnUndo: document.getElementById("btn-undo"),
btnRedo: document.getElementById("btn-redo"),
responseBanner: document.getElementById("response-banner"),
progressPill: document.getElementById("progress-pill"),
pipelineSteps: document.getElementById("pipeline-steps"),
capabilityTags: document.getElementById("capability-tags"),
presetGrid: document.getElementById("preset-grid"),
historyList: document.getElementById("history-list"),
layerList: document.getElementById("layer-list"),
batchPanel: document.getElementById("batch-panel"),
batchSourceGrid: document.getElementById("batch-source-grid"),
batchResultGrid: document.getElementById("batch-result-grid"),
generateControls: document.getElementById("generate-controls"),
generateWidth: document.getElementById("generate-width"),
generateHeight: document.getElementById("generate-height"),
upscaleControls: document.getElementById("upscale-controls"),
upscaleScale: document.getElementById("upscale-scale"),
upscaleScaleValue: document.getElementById("upscale-scale-value"),
stepsSlider: document.getElementById("steps-slider"),
stepsValue: document.getElementById("steps-value"),
textScaleSlider: document.getElementById("text-scale-slider"),
textScaleValue: document.getElementById("text-scale-value"),
imageScaleSlider: document.getElementById("image-scale-slider"),
imageScaleValue: document.getElementById("image-scale-value"),
seedInput: document.getElementById("seed-input"),
toastStack: document.getElementById("toast-stack"),
adjustWarmth: document.getElementById("adjust-warmth"),
adjustBrightness: document.getElementById("adjust-brightness"),
adjustContrast: document.getElementById("adjust-contrast"),
adjustClarity: document.getElementById("adjust-clarity"),
adjustSharpness: document.getElementById("adjust-sharpness"),
adjustVignette: document.getElementById("adjust-vignette"),
adjustBloom: document.getElementById("adjust-bloom"),
btnResetAdjust: document.getElementById("btn-reset-adjust"),
};
const maskCtx = dom.maskCanvas.getContext("2d");
function escapeHtml(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function showToast(title, body) {
const toast = document.createElement("div");
toast.className = "toast";
toast.innerHTML = `${escapeHtml(title)}
${escapeHtml(body)}
`;
dom.toastStack.appendChild(toast);
setTimeout(() => toast.remove(), 4200);
}
function setResponse(message = "", isError = false) {
if (!message) {
dom.responseBanner.hidden = true;
dom.responseBanner.textContent = "";
dom.responseBanner.classList.remove("error");
return;
}
dom.responseBanner.hidden = false;
dom.responseBanner.textContent = message;
dom.responseBanner.classList.toggle("error", isError);
}
function setProgress(text) {
dom.progressPill.textContent = text;
}
function renderStepChips(steps = []) {
dom.pipelineSteps.innerHTML = "";
steps.forEach((step) => {
const chip = document.createElement("span");
chip.className = "step-chip";
chip.textContent = step.replaceAll("_", " ");
dom.pipelineSteps.appendChild(chip);
});
}
function renderCapabilityTags() {
dom.capabilityTags.innerHTML = "";
const suggestions = {
edit: [
{ label: "✨ Warm Sunset", prompt: "Apply warm cinematic golden hour sunset lighting with gorgeous glowing highlights" },
{ label: "📸 Glamour Editorial", prompt: "Create a high-end luxury fashion editorial, DSLR photography, shallow depth of field, magazine-cover quality" },
{ label: "🌃 Cyberpunk Neon", prompt: "Transform the scene into a vibrant cyberpunk night style with glowing neon lights and high clarity" },
{ label: "🎭 Dramatic Matte", prompt: "Enhance image with dramatic matte curves, lifted shadows, and moody ambient colors" }
],
generate: [
{ label: "🏝️ Sunset Beach", prompt: "A gorgeous luxury tropical beach resort at sunset with towering palm trees, cinematic DSLR photography" },
{ label: "🚀 Neon Future", prompt: "A futuristic cyberpunk skyline at night with neon signs, wet rainy streets reflecting lights, high detail" },
{ label: "🌄 Alpine Lake", prompt: "A serene pristine mountain lake mirroring snowy alpine peaks at sunrise, warm morning sun" }
],
inpaint: [
{ label: "👚 Change Outfit", prompt: "change the outfit of the person in the masked region to elegant high-fashion designer wear" },
{ label: "🚫 Remove Object", prompt: "remove the object in the masked region completely, inpaint and blend the background seamlessly" },
{ label: "🎨 Add Custom Patch", prompt: "add a custom visual design element inside the masked region, matching the lighting" }
],
style: [
{ label: "🌅 Golden Warmth", prompt: "Apply warm rich color harmony matching a gorgeous golden hour landscape" },
{ label: "🌌 Cyber Neon", prompt: "Match the style to a cold neon city night look with high teal and magenta saturation" }
],
background: [
{ label: "🌴 Sunset Shoreline", prompt: "seamlessly replace the background with a tropical sunset beach and palm trees at golden hour" },
{ label: "🌆 Cyberpunk Street", prompt: "replace the background with a vibrant cyberpunk neon street at night, wet asphalt reflections" },
{ label: "📸 Portrait Studio", prompt: "replace the background with a soft, warm dreamy studio backdrop, high-end portrait lighting" }
],
upscale: [
{ label: "✨ Super Details", prompt: "Enhance high-frequency textures, restore micro-contrast, and sharpen edges naturally" }
],
batch: [
{ label: "🌅 Warm Grade Batch", prompt: "Apply warm color grade, soft contrast, and subtle vignettes across all images" }
]
}[state.mode] || [];
suggestions.forEach((item) => {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "mini-chip suggestion-chip";
chip.textContent = item.label;
chip.addEventListener("click", () => {
dom.promptInput.value = item.prompt;
showToast("Suggestion Applied", item.label);
updateRunButton();
});
dom.capabilityTags.appendChild(chip);
});
}
function renderPresets() {
dom.presetGrid.innerHTML = "";
state.presets.forEach((preset) => {
const card = document.createElement("button");
card.type = "button";
card.className = "preset-card";
const tags = (preset.steps || []).slice(0, 3).map((step) => `${escapeHtml(step.name.replaceAll("_", " "))}`).join("");
card.innerHTML = `
${escapeHtml(preset.name)}
${escapeHtml(preset.description)}
${tags}
`;
card.addEventListener("click", () => {
dom.promptInput.value = preset.name + " look";
setResponse(`Loaded preset "${preset.name}". You can run it directly or adapt the prompt.`);
renderStepChips((preset.steps || []).map((step) => step.name));
showToast("Preset Loaded", preset.name);
updateRunButton();
});
dom.presetGrid.appendChild(card);
});
}
function renderLayers(steps = []) {
dom.layerList.innerHTML = "";
if (!steps.length) {
dom.layerList.innerHTML = `Waiting for output
Pipeline steps from the next result will appear here.
`;
return;
}
steps.forEach((step, index) => {
const item = document.createElement("div");
item.className = "layer-item";
item.innerHTML = `
Layer ${index + 1}
${escapeHtml(step.replaceAll("_", " "))}
active
`;
dom.layerList.appendChild(item);
});
}
function saveHistory() {
localStorage.setItem(state.storageKey, JSON.stringify({
history: state.history.slice(-24),
historyIndex: state.historyIndex,
}));
}
function loadHistory() {
try {
const raw = localStorage.getItem(state.storageKey);
if (!raw) return;
const parsed = JSON.parse(raw);
state.history = Array.isArray(parsed.history) ? parsed.history : [];
state.historyIndex = typeof parsed.historyIndex === "number" ? parsed.historyIndex : state.history.length - 1;
} catch {
state.history = [];
state.historyIndex = -1;
}
}
function renderHistory() {
dom.historyList.innerHTML = "";
if (!state.history.length) {
dom.historyList.innerHTML = `No edits yet
Your recent outputs will be stored locally here.
`;
return;
}
[...state.history].reverse().forEach((entry) => {
const active = state.history[state.historyIndex] && state.history[state.historyIndex].id === entry.id;
const item = document.createElement("button");
item.type = "button";
item.className = "history-card-item";
item.style.borderColor = active ? "rgba(255, 179, 106, 0.42)" : "";
item.innerHTML = `
${escapeHtml(entry.mode.toUpperCase())}
${escapeHtml(entry.prompt || "No prompt")}
${(entry.steps || []).slice(0, 3).map((step) => `${escapeHtml(step.replaceAll("_", " "))}`).join("")}
`;
item.addEventListener("click", () => restoreHistoryEntry(entry.id));
dom.historyList.appendChild(item);
});
}
function pushHistory(entry) {
const historyEntry = {
id: crypto.randomUUID(),
timestamp: Date.now(),
...entry,
};
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(historyEntry);
state.historyIndex = state.history.length - 1;
saveHistory();
renderHistory();
}
function restoreHistoryEntry(id) {
const index = state.history.findIndex((entry) => entry.id === id);
if (index < 0) return;
state.historyIndex = index;
const entry = state.history[index];
state.mode = entry.mode || state.mode;
state.resultDataUrl = entry.resultDataUrl || "";
state.sourceDataUrl = entry.sourceDataUrl || state.sourceDataUrl;
dom.promptInput.value = entry.prompt || "";
updateModeUI();
renderStage();
renderLayers(entry.steps || []);
setResponse(`Restored history item from ${new Date(entry.timestamp).toLocaleString()}.`);
saveHistory();
renderHistory();
}
function undoHistory() {
if (state.historyIndex <= 0) return;
state.historyIndex -= 1;
restoreHistoryEntry(state.history[state.historyIndex].id);
}
function redoHistory() {
if (state.historyIndex >= state.history.length - 1) return;
state.historyIndex += 1;
restoreHistoryEntry(state.history[state.historyIndex].id);
}
function applyStageTransform() {
dom.stageMedia.style.transform = `translate(${state.stageOffsetX}px, ${state.stageOffsetY}px) scale(${state.stageScale})`;
}
function resetStageTransform() {
state.stageScale = 1;
state.stageOffsetX = 0;
state.stageOffsetY = 0;
applyStageTransform();
}
function applyCompare() {
const compareValue = state.mask.enabled ? 100 : dom.compareSlider.value;
document.documentElement.style.setProperty("--compare", `${compareValue}%`);
dom.compareOverlay.hidden = !state.sourceDataUrl || (!state.compareMode && !state.mask.enabled);
dom.compareSlider.hidden = !state.compareMode || !state.sourceDataUrl || state.mask.enabled;
}
function renderStage() {
const hasSource = Boolean(state.sourceDataUrl);
const resultUrl = state.resultDataUrl || state.sourceDataUrl;
dom.sourcePreview.hidden = !hasSource;
dom.sourcePreview.src = hasSource ? state.sourceDataUrl : "";
dom.resultPreview.hidden = !resultUrl;
dom.resultPreview.src = resultUrl || "";
dom.stageEmpty.hidden = Boolean(hasSource || state.resultDataUrl);
dom.btnDownload.disabled = !state.resultDataUrl;
applyCompare();
syncMaskCanvas();
}
function updateRunButton() {
const hasPrompt = Boolean(dom.promptInput.value.trim());
const hasSource = Boolean(state.sourceFile || state.sourceDataUrl);
const hasReference = Boolean(state.referenceFile);
const hasBatch = state.batchFiles.length > 0;
let canRun = false;
if (state.mode === "edit") canRun = hasSource && hasPrompt;
if (state.mode === "generate") canRun = hasPrompt;
if (state.mode === "inpaint") canRun = hasSource && hasPrompt && state.mask.hasData;
if (state.mode === "style") canRun = hasSource && hasReference;
if (state.mode === "background") canRun = hasSource && hasPrompt;
if (state.mode === "upscale") canRun = hasSource;
if (state.mode === "batch") canRun = hasBatch && hasPrompt;
dom.runButton.disabled = state.isBusy || !canRun;
dom.btnUndo.disabled = state.historyIndex <= 0;
dom.btnRedo.disabled = state.historyIndex >= state.history.length - 1 || state.history.length === 0;
}
function setMode(mode) {
state.mode = mode;
updateModeUI();
renderCapabilityTags();
updateRunButton();
setResponse("");
}
function updateModeUI() {
dom.modeTabs.forEach((button) => button.classList.toggle("active", button.dataset.mode === state.mode));
dom.modeBlurb.textContent = modeDescriptions[state.mode];
dom.modeCopy.textContent = modeDescriptions[state.mode];
dom.runButton.textContent = {
edit: "Run Edit",
generate: "Generate",
inpaint: "Apply Inpaint",
style: "Transfer Style",
background: "Replace Background",
upscale: "Upscale",
batch: "Run Batch",
}[state.mode];
dom.generateControls.hidden = state.mode !== "generate";
dom.upscaleControls.hidden = state.mode !== "upscale";
dom.maskPanel.hidden = state.mode !== "inpaint";
dom.batchPanel.hidden = state.mode !== "batch";
state.mask.enabled = state.mode === "inpaint";
dom.referenceDrop.hidden = !(state.mode === "style" || state.mode === "edit");
dom.backgroundDrop.hidden = !(state.mode === "background" || state.mode === "edit");
dom.batchDrop.hidden = state.mode !== "batch";
dom.sourceDrop.hidden = state.mode === "generate";
dom.sourceMeta.textContent = state.mode === "inpaint"
? "Drop an image to paint a repair mask"
: "Drop or browse PNG, JPG, WebP";
applyCompare();
syncMaskCanvas();
}
function updateSliderLabels() {
dom.stepsValue.textContent = dom.stepsSlider.value;
dom.textScaleValue.textContent = dom.textScaleSlider.value;
dom.imageScaleValue.textContent = dom.imageScaleSlider.value;
dom.upscaleScaleValue.textContent = `${Number(dom.upscaleScale.value).toFixed(1)}x`;
dom.maskSizeValue.textContent = dom.maskSize.value;
state.mask.size = Number(dom.maskSize.value);
if (dom.adjustWarmth) {
document.getElementById("adjust-warmth-value").textContent = Number(dom.adjustWarmth.value).toFixed(2);
document.getElementById("adjust-brightness-value").textContent = Number(dom.adjustBrightness.value).toFixed(2);
document.getElementById("adjust-contrast-value").textContent = Number(dom.adjustContrast.value).toFixed(2);
document.getElementById("adjust-clarity-value").textContent = Number(dom.adjustClarity.value).toFixed(2);
document.getElementById("adjust-sharpness-value").textContent = Number(dom.adjustSharpness.value).toFixed(2);
document.getElementById("adjust-vignette-value").textContent = Number(dom.adjustVignette.value).toFixed(2);
document.getElementById("adjust-bloom-value").textContent = Number(dom.adjustBloom.value).toFixed(2);
}
}
async function dataUrlToBlob(dataUrl) {
const res = await fetch(dataUrl);
return await res.blob();
}
let adjustLock = false;
let adjustPending = false;
async function triggerAdjustment() {
if (!state.sourceDataUrl) return;
if (adjustLock) {
adjustPending = true;
return;
}
adjustLock = true;
adjustPending = false;
try {
const sourceBlob = await dataUrlToBlob(state.sourceDataUrl);
const formData = new FormData();
formData.append("image", sourceBlob, "source.png");
formData.append("warmth", dom.adjustWarmth.value);
formData.append("brightness", dom.adjustBrightness.value);
formData.append("contrast", dom.adjustContrast.value);
formData.append("clarity", dom.adjustClarity.value);
formData.append("sharpness", dom.adjustSharpness.value);
formData.append("vignette", dom.adjustVignette.value);
formData.append("bloom", dom.adjustBloom.value);
const res = await fetch("/adjust", {
method: "POST",
body: formData,
});
const data = await res.json();
if (data.success && data.image_base64) {
state.resultDataUrl = `data:image/png;base64,${data.image_base64}`;
renderStage();
}
} catch (e) {
console.error("Adjustment error:", e);
} finally {
adjustLock = false;
if (adjustPending) {
triggerAdjustment();
}
}
}
async function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function loadSourceFile(file) {
if (!file || !file.type.startsWith("image/")) return;
state.sourceFile = file;
state.sourceDataUrl = await fileToDataUrl(file);
state.resultDataUrl = "";
dom.sourceMeta.textContent = `${file.name} - ${(file.size / 1024 / 1024).toFixed(2)} MB`;
clearMask();
renderStage();
updateRunButton();
}
async function loadReferenceFile(file) {
if (!file || !file.type.startsWith("image/")) return;
state.referenceFile = file;
dom.referenceMeta.textContent = file.name;
updateRunButton();
showToast("Reference Loaded", file.name);
}
async function loadBackgroundFile(file) {
if (!file || !file.type.startsWith("image/")) return;
state.backgroundFile = file;
dom.backgroundMeta.textContent = file.name;
updateRunButton();
showToast("Background Loaded", file.name);
}
function renderBatchSources() {
dom.batchSourceGrid.innerHTML = "";
state.batchFiles.forEach((file) => {
const card = document.createElement("div");
card.className = "thumb-card";
const url = URL.createObjectURL(file);
card.innerHTML = `
${escapeHtml(file.name)}`;
dom.batchSourceGrid.appendChild(card);
});
dom.batchMeta.textContent = state.batchFiles.length ? `${state.batchFiles.length} file(s) queued` : "Multiple inputs for batch mode";
}
function renderBatchResults(items = []) {
dom.batchResultGrid.innerHTML = "";
items.forEach((item, index) => {
if (!item.image_base64) return;
const card = document.createElement("div");
card.className = "thumb-card";
card.innerHTML = `
Result ${index + 1}`;
dom.batchResultGrid.appendChild(card);
});
}
function setupUploadTile(tile, input, onFiles, multiple = false) {
tile.addEventListener("click", () => input.click());
tile.addEventListener("dragover", (event) => {
event.preventDefault();
tile.style.borderColor = "rgba(255, 179, 106, 0.55)";
});
tile.addEventListener("dragleave", () => {
tile.style.borderColor = "";
});
tile.addEventListener("drop", async (event) => {
event.preventDefault();
tile.style.borderColor = "";
const files = multiple ? [...event.dataTransfer.files] : [event.dataTransfer.files[0]];
await onFiles(files);
});
input.addEventListener("change", async () => {
const files = multiple ? [...input.files] : [input.files[0]];
await onFiles(files);
});
}
function ensureMaskBase() {
if (!dom.maskCanvas.width || !dom.maskCanvas.height) {
syncMaskCanvas(true);
}
maskCtx.save();
maskCtx.globalCompositeOperation = "source-over";
maskCtx.fillStyle = "black";
maskCtx.fillRect(0, 0, dom.maskCanvas.width, dom.maskCanvas.height);
maskCtx.restore();
}
function clearMask() {
ensureMaskBase();
state.mask.hasData = false;
dom.maskCanvas.hidden = true;
}
function syncMaskCanvas(force = false) {
if (!state.sourceDataUrl || !state.mask.enabled) {
dom.maskCanvas.hidden = true;
return;
}
const image = dom.sourcePreview;
if (!image.naturalWidth || !image.naturalHeight) return;
if (force || dom.maskCanvas.width !== image.naturalWidth || dom.maskCanvas.height !== image.naturalHeight) {
dom.maskCanvas.width = image.naturalWidth;
dom.maskCanvas.height = image.naturalHeight;
maskCtx.fillStyle = "black";
maskCtx.fillRect(0, 0, dom.maskCanvas.width, dom.maskCanvas.height);
state.mask.hasData = false;
}
dom.maskCanvas.hidden = false;
}
function getCanvasPoint(event) {
const rect = dom.maskCanvas.getBoundingClientRect();
const scaleX = dom.maskCanvas.width / rect.width;
const scaleY = dom.maskCanvas.height / rect.height;
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
};
}
function drawMaskLine(from, to, pressure = 0.5) {
const size = state.mask.size * Math.max(0.4, pressure || 0.5);
maskCtx.save();
if (state.mask.tool === "erase") {
maskCtx.globalCompositeOperation = "destination-out";
maskCtx.strokeStyle = "rgba(0,0,0,1)";
} else {
maskCtx.globalCompositeOperation = "source-over";
maskCtx.strokeStyle = "white";
}
maskCtx.lineCap = "round";
maskCtx.lineJoin = "round";
maskCtx.lineWidth = size;
maskCtx.beginPath();
maskCtx.moveTo(from.x, from.y);
maskCtx.lineTo(to.x, to.y);
maskCtx.stroke();
maskCtx.restore();
state.mask.hasData = true;
}
function exportMaskBlob() {
return new Promise((resolve) => {
if (!state.mask.hasData) {
resolve(null);
return;
}
dom.maskCanvas.toBlob((blob) => resolve(blob), "image/png");
});
}
function setMaskTool(tool) {
state.mask.tool = tool;
dom.maskBrush.classList.toggle("active", tool === "brush");
dom.maskErase.classList.toggle("active", tool === "erase");
}
function startProgress() {
stopProgress();
const messages = progressMessages[state.mode] || ["Working..."];
let index = 0;
setProgress(messages[index]);
state.progressTimer = window.setInterval(() => {
index = (index + 1) % messages.length;
setProgress(messages[index]);
}, 1800);
}
function stopProgress() {
if (state.progressTimer) {
window.clearInterval(state.progressTimer);
state.progressTimer = null;
}
setProgress("Idle");
}
async function fetchHealth() {
try {
const response = await fetch("/health");
const data = await response.json();
state.info = data;
dom.statusDot.className = "status-dot online";
dom.statusText.textContent = data.model_loaded ? `Ready - ${data.provider_label}` : `Starting - ${data.provider_label}`;
dom.promptHint.textContent = data.prompt_hint || "Describe your edit.";
dom.capabilityCount.textContent = data.capability_count;
dom.presetCount.textContent = data.preset_count;
} catch {
dom.statusDot.className = "status-dot error";
dom.statusText.textContent = "Disconnected";
dom.promptHint.textContent = "The local server could not be reached.";
}
}
async function fetchCapabilities() {
try {
const response = await fetch("/capabilities");
const data = await response.json();
state.capabilities = data.capabilities || [];
renderCapabilityTags();
} catch {
state.capabilities = [];
}
}
async function fetchPresets() {
try {
const response = await fetch("/presets");
const data = await response.json();
state.presets = data.presets || [];
renderPresets();
} catch {
state.presets = [];
renderPresets();
}
}
function buildCommonFormData(formData) {
formData.append("num_steps", dom.stepsSlider.value);
formData.append("text_guidance_scale", dom.textScaleSlider.value);
formData.append("image_guidance_scale", dom.imageScaleSlider.value);
if (dom.seedInput.value) formData.append("seed", dom.seedInput.value);
}
async function runCurrentMode() {
if (state.isBusy) return;
const prompt = dom.promptInput.value.trim();
const seed = dom.seedInput.value;
try {
state.isBusy = true;
updateRunButton();
setResponse("");
startProgress();
renderStepChips([]);
let endpoint = "/edit";
let response;
if (state.mode === "edit") {
const formData = new FormData();
formData.append("image", state.sourceFile);
formData.append("prompt", prompt);
buildCommonFormData(formData);
if (state.referenceFile) formData.append("reference_image", state.referenceFile);
if (state.backgroundFile) formData.append("background_image", state.backgroundFile);
const maskBlob = await exportMaskBlob();
if (maskBlob) formData.append("mask", new File([maskBlob], "mask.png", { type: "image/png" }));
response = await fetch("/edit", { method: "POST", body: formData });
} else if (state.mode === "generate") {
const formData = new FormData();
formData.append("prompt", prompt);
formData.append("width", dom.generateWidth.value);
formData.append("height", dom.generateHeight.value);
buildCommonFormData(formData);
response = await fetch("/generate", { method: "POST", body: formData });
} else if (state.mode === "inpaint") {
const maskBlob = await exportMaskBlob();
if (!maskBlob) throw new Error("Paint a mask before running inpaint.");
const formData = new FormData();
formData.append("image", state.sourceFile);
formData.append("mask", new File([maskBlob], "mask.png", { type: "image/png" }));
formData.append("prompt", prompt || "repair the masked region naturally");
buildCommonFormData(formData);
response = await fetch("/inpaint", { method: "POST", body: formData });
} else if (state.mode === "style") {
const formData = new FormData();
formData.append("image", state.sourceFile);
formData.append("reference_image", state.referenceFile);
formData.append("prompt", prompt);
if (seed) formData.append("seed", seed);
response = await fetch("/style-transfer", { method: "POST", body: formData });
} else if (state.mode === "background") {
const formData = new FormData();
formData.append("image", state.sourceFile);
formData.append("prompt", prompt);
if (state.backgroundFile) formData.append("background_image", state.backgroundFile);
if (seed) formData.append("seed", seed);
response = await fetch("/background", { method: "POST", body: formData });
} else if (state.mode === "upscale") {
const formData = new FormData();
formData.append("image", state.sourceFile);
formData.append("scale", dom.upscaleScale.value);
formData.append("prompt", prompt);
if (seed) formData.append("seed", seed);
response = await fetch("/upscale", { method: "POST", body: formData });
} else if (state.mode === "batch") {
const formData = new FormData();
state.batchFiles.forEach((file) => formData.append("images", file));
formData.append("prompt", prompt);
buildCommonFormData(formData);
response = await fetch("/batch", { method: "POST", body: formData });
}
const data = await response.json();
if (state.mode === "batch") {
if (!data.success) throw new Error(data.message || "Batch request failed.");
renderBatchResults(data.items || []);
if (data.items && data.items[0] && data.items[0].image_base64) {
state.resultDataUrl = `data:image/png;base64,${data.items[0].image_base64}`;
}
renderStage();
setResponse(data.message || "Batch complete.");
renderLayers(((data.items || [])[0] || {}).steps || []);
showToast("Batch Complete", `${(data.items || []).length} result(s) rendered.`);
return;
}
if (!data.success || !data.image_base64) {
throw new Error(data.message || "Request failed.");
}
state.resultDataUrl = `data:image/png;base64,${data.image_base64}`;
renderStage();
renderLayers(data.steps || []);
renderStepChips(data.steps || []);
setResponse(data.message || "Operation complete.");
pushHistory({
mode: state.mode,
prompt,
resultDataUrl: state.resultDataUrl,
sourceDataUrl: state.sourceDataUrl,
steps: data.steps || [],
message: data.message || "",
});
showToast("Completed", `${state.mode} finished successfully.`);
} catch (error) {
setResponse(error.message || "Something went wrong.", true);
showToast("Request Failed", error.message || "Something went wrong.");
} finally {
state.isBusy = false;
stopProgress();
updateRunButton();
}
}
function downloadResult() {
if (!state.resultDataUrl) return;
const link = document.createElement("a");
link.href = state.resultDataUrl;
link.download = `imageeditter_${state.mode}_${Date.now()}.png`;
link.click();
}
function handleKeyboard(event) {
const isModifier = event.ctrlKey || event.metaKey;
if (isModifier && event.key.toLowerCase() === "z" && !event.shiftKey) {
event.preventDefault();
undoHistory();
}
if (isModifier && (event.key.toLowerCase() === "y" || (event.shiftKey && event.key.toLowerCase() === "z"))) {
event.preventDefault();
redoHistory();
}
if (isModifier && event.key.toLowerCase() === "s") {
event.preventDefault();
downloadResult();
}
if (state.mode === "inpaint" && event.key === "[") {
event.preventDefault();
dom.maskSize.value = Math.max(4, Number(dom.maskSize.value) - 2);
updateSliderLabels();
}
if (state.mode === "inpaint" && event.key === "]") {
event.preventDefault();
dom.maskSize.value = Math.min(80, Number(dom.maskSize.value) + 2);
updateSliderLabels();
}
}
setupUploadTile(dom.sourceDrop, dom.sourceInput, async (files) => {
if (files[0]) await loadSourceFile(files[0]);
});
setupUploadTile(dom.referenceDrop, dom.referenceInput, async (files) => {
if (files[0]) await loadReferenceFile(files[0]);
});
setupUploadTile(dom.backgroundDrop, dom.backgroundInput, async (files) => {
if (files[0]) await loadBackgroundFile(files[0]);
});
setupUploadTile(dom.batchDrop, dom.batchInput, async (files) => {
state.batchFiles = files.filter((file) => file && file.type.startsWith("image/"));
renderBatchSources();
updateRunButton();
}, true);
dom.modeTabs.forEach((button) => button.addEventListener("click", () => setMode(button.dataset.mode)));
dom.runButton.addEventListener("click", runCurrentMode);
dom.btnUndo.addEventListener("click", undoHistory);
dom.btnRedo.addEventListener("click", redoHistory);
dom.btnDownload.addEventListener("click", downloadResult);
dom.btnClearAll.addEventListener("click", () => {
state.sourceFile = null;
state.referenceFile = null;
state.backgroundFile = null;
state.batchFiles = [];
state.sourceDataUrl = "";
state.resultDataUrl = "";
dom.sourceMeta.textContent = "Drop or browse PNG, JPG, WebP";
dom.referenceMeta.textContent = "Used for style transfer";
dom.backgroundMeta.textContent = "Optional replacement backdrop";
dom.batchMeta.textContent = "Multiple inputs for batch mode";
dom.batchSourceGrid.innerHTML = "";
dom.batchResultGrid.innerHTML = "";
dom.promptInput.value = "";
clearMask();
renderStage();
renderLayers([]);
renderStepChips([]);
setResponse("");
resetStageTransform();
updateRunButton();
});
dom.btnViewToggle.addEventListener("click", () => {
state.compareMode = !state.compareMode;
dom.btnViewToggle.textContent = state.compareMode ? "Compare" : "Result Only";
applyCompare();
});
dom.btnFullscreen.addEventListener("click", () => {
state.fullscreen = !state.fullscreen;
document.body.classList.toggle("fullscreen-mode", state.fullscreen);
});
dom.compareSlider.addEventListener("input", applyCompare);
dom.stepsSlider.addEventListener("input", updateSliderLabels);
dom.textScaleSlider.addEventListener("input", updateSliderLabels);
dom.imageScaleSlider.addEventListener("input", updateSliderLabels);
dom.upscaleScale.addEventListener("input", updateSliderLabels);
dom.maskSize.addEventListener("input", updateSliderLabels);
dom.promptInput.addEventListener("input", updateRunButton);
const adjustSliders = [
dom.adjustWarmth,
dom.adjustBrightness,
dom.adjustContrast,
dom.adjustClarity,
dom.adjustSharpness,
dom.adjustVignette,
dom.adjustBloom
];
adjustSliders.forEach(slider => {
if (slider) {
slider.addEventListener("input", () => {
updateSliderLabels();
triggerAdjustment();
});
}
});
if (dom.btnResetAdjust) {
dom.btnResetAdjust.addEventListener("click", () => {
dom.adjustWarmth.value = "1.0";
dom.adjustBrightness.value = "1.0";
dom.adjustContrast.value = "1.0";
dom.adjustClarity.value = "1.0";
dom.adjustSharpness.value = "0.0";
dom.adjustVignette.value = "0.0";
dom.adjustBloom.value = "0.0";
updateSliderLabels();
triggerAdjustment();
showToast("Adjustments Reset", "All fine-tuning sliders set to defaults");
});
}
dom.maskMagic.addEventListener("click", async () => {
if (!state.sourceDataUrl) {
showToast("No Source Image", "Please upload a source image first.");
return;
}
try {
state.isBusy = true;
updateRunButton();
showToast("Magic Selecting...", "Segmenting foreground subject using local GrabCut AI...");
const sourceBlob = await dataUrlToBlob(state.sourceDataUrl);
const formData = new FormData();
formData.append("image", sourceBlob, "source.png");
const res = await fetch("/segment", {
method: "POST",
body: formData,
});
const data = await res.json();
if (data.success && data.mask_base64) {
const maskImg = new Image();
maskImg.onload = () => {
syncMaskCanvas(true);
maskCtx.save();
maskCtx.globalCompositeOperation = "source-over";
maskCtx.drawImage(maskImg, 0, 0, dom.maskCanvas.width, dom.maskCanvas.height);
maskCtx.restore();
state.mask.hasData = true;
showToast("Magic Selected!", "Subject successfully isolated! You can now run inpaint or background replace.");
updateRunButton();
};
maskImg.src = `data:image/png;base64,${data.mask_base64}`;
} else {
throw new Error(data.message || "Failed to segment.");
}
} catch (e) {
showToast("Selection Failed", e.message || "Something went wrong.");
} finally {
state.isBusy = false;
updateRunButton();
}
});
dom.maskBrush.addEventListener("click", () => setMaskTool("brush"));
dom.maskErase.addEventListener("click", () => setMaskTool("erase"));
dom.maskFill.addEventListener("click", () => {
syncMaskCanvas(true);
maskCtx.save();
maskCtx.globalCompositeOperation = "source-over";
maskCtx.fillStyle = "white";
maskCtx.fillRect(0, 0, dom.maskCanvas.width, dom.maskCanvas.height);
maskCtx.restore();
state.mask.hasData = true;
});
dom.maskClear.addEventListener("click", clearMask);
dom.maskCanvas.addEventListener("pointerdown", (event) => {
if (!state.mask.enabled || !state.sourceDataUrl) return;
syncMaskCanvas();
state.mask.drawing = true;
dom.maskCanvas.style.pointerEvents = "auto";
state.mask.lastPoint = getCanvasPoint(event);
drawMaskLine(state.mask.lastPoint, state.mask.lastPoint, event.pressure || 0.5);
dom.maskCanvas.setPointerCapture(event.pointerId);
});
dom.maskCanvas.addEventListener("pointermove", (event) => {
if (!state.mask.drawing) return;
const point = getCanvasPoint(event);
drawMaskLine(state.mask.lastPoint, point, event.pressure || 0.5);
state.mask.lastPoint = point;
});
const stopMaskDrawing = () => {
state.mask.drawing = false;
state.mask.lastPoint = null;
};
dom.maskCanvas.addEventListener("pointerup", stopMaskDrawing);
dom.maskCanvas.addEventListener("pointercancel", stopMaskDrawing);
dom.maskCanvas.addEventListener("pointerleave", () => {
if (!state.mask.drawing) return;
stopMaskDrawing();
});
dom.stageFrame.addEventListener("wheel", (event) => {
if (state.mask.enabled && state.mask.drawing) return;
event.preventDefault();
const direction = event.deltaY > 0 ? -0.08 : 0.08;
state.stageScale = Math.min(4, Math.max(0.6, state.stageScale + direction));
applyStageTransform();
});
dom.stageFrame.addEventListener("pointerdown", (event) => {
if (state.mask.enabled) return;
state.stageDragging = true;
state.stageDragOrigin = {
x: event.clientX - state.stageOffsetX,
y: event.clientY - state.stageOffsetY,
};
});
window.addEventListener("pointermove", (event) => {
if (!state.stageDragging || !state.stageDragOrigin) return;
state.stageOffsetX = event.clientX - state.stageDragOrigin.x;
state.stageOffsetY = event.clientY - state.stageDragOrigin.y;
applyStageTransform();
});
window.addEventListener("pointerup", () => {
state.stageDragging = false;
state.stageDragOrigin = null;
});
dom.stageFrame.addEventListener("dblclick", resetStageTransform);
document.addEventListener("keydown", handleKeyboard);
dom.sourcePreview.addEventListener("load", () => {
syncMaskCanvas(true);
renderStage();
});
updateSliderLabels();
loadHistory();
renderHistory();
renderLayers([]);
renderCapabilityTags();
updateModeUI();
renderStage();
updateRunButton();
fetchHealth();
fetchCapabilities();
fetchPresets();
setInterval(fetchHealth, 12000);
});