Spaces:
Sleeping
Sleeping
| 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 = `<strong>${escapeHtml(title)}</strong><p>${escapeHtml(body)}</p>`; | |
| 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) => `<span class="mini-chip">${escapeHtml(step.name.replaceAll("_", " "))}</span>`).join(""); | |
| card.innerHTML = ` | |
| <h3>${escapeHtml(preset.name)}</h3> | |
| <p>${escapeHtml(preset.description)}</p> | |
| <div class="preset-tags">${tags}</div> | |
| `; | |
| 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 = `<div class="layer-item"><h3>Waiting for output</h3><p>Pipeline steps from the next result will appear here.</p></div>`; | |
| return; | |
| } | |
| steps.forEach((step, index) => { | |
| const item = document.createElement("div"); | |
| item.className = "layer-item"; | |
| item.innerHTML = ` | |
| <h3>Layer ${index + 1}</h3> | |
| <p>${escapeHtml(step.replaceAll("_", " "))}</p> | |
| <div class="layer-tags"><span class="mini-chip">active</span></div> | |
| `; | |
| 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 = `<div class="history-card-item"><div></div><div><h3>No edits yet</h3><p>Your recent outputs will be stored locally here.</p></div></div>`; | |
| 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 = ` | |
| <img src="${entry.resultDataUrl}" alt="${escapeHtml(entry.mode)} history preview"> | |
| <div> | |
| <h3>${escapeHtml(entry.mode.toUpperCase())}</h3> | |
| <p>${escapeHtml(entry.prompt || "No prompt")}</p> | |
| <div class="history-tags"> | |
| ${(entry.steps || []).slice(0, 3).map((step) => `<span class="mini-chip">${escapeHtml(step.replaceAll("_", " "))}</span>`).join("")} | |
| </div> | |
| </div> | |
| `; | |
| 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 = `<img src="${url}" alt="${escapeHtml(file.name)}"><span>${escapeHtml(file.name)}</span>`; | |
| 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 = `<img src="data:image/png;base64,${item.image_base64}" alt="Batch result ${index + 1}"><span>Result ${index + 1}</span>`; | |
| 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); | |
| }); | |