diff --git a/Image_editor_example/image_editor.js b/Image_editor_example/image_editor.js
deleted file mode 100644
index 8129d31..0000000
--- a/Image_editor_example/image_editor.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { app } from "/scripts/app.js";
-import { api } from "/scripts/api.js";
-import { injectImageEditorStyles } from "./image_editor_modules/styles.js";
-import { registerImageEditorExtension } from "./image_editor_modules/extension.js";
-
-injectImageEditorStyles();
-registerImageEditorExtension(app, api);
diff --git a/Image_editor_example/image_editor_modules/color.js b/Image_editor_example/image_editor_modules/color.js
deleted file mode 100644
index 2c50838..0000000
--- a/Image_editor_example/image_editor_modules/color.js
+++ /dev/null
@@ -1,71 +0,0 @@
-export function clamp01(value) {
- return Math.min(1, Math.max(0, value));
-}
-
-export function clamp255(value) {
- return Math.min(255, Math.max(0, value));
-}
-
-export function rgbToHsl(r, g, b) {
- r /= 255;
- g /= 255;
- b /= 255;
- const max = Math.max(r, g, b);
- const min = Math.min(r, g, b);
- let h;
- let s;
- const l = (max + min) / 2;
- if (max === min) {
- h = 0;
- s = 0;
- } else {
- const d = max - min;
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
- switch (max) {
- case r:
- h = (g - b) / d + (g < b ? 6 : 0);
- break;
- case g:
- h = (b - r) / d + 2;
- break;
- default:
- h = (r - g) / d + 4;
- }
- h /= 6;
- }
- return { h, s, l };
-}
-
-export function hslToRgb(h, s, l) {
- let r;
- let g;
- let b;
- if (s === 0) {
- r = g = b = l;
- } else {
- const hue2rgb = (p, q, t) => {
- if (t < 0) t += 1;
- if (t > 1) t -= 1;
- if (t < 1 / 6) return p + (q - p) * 6 * t;
- if (t < 1 / 2) return q;
- if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
- return p;
- };
- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
- const p = 2 * l - q;
- r = hue2rgb(p, q, h + 1 / 3);
- g = hue2rgb(p, q, h);
- b = hue2rgb(p, q, h - 1 / 3);
- }
- return {
- r: Math.round(r * 255),
- g: Math.round(g * 255),
- b: Math.round(b * 255)
- };
-}
-
-export function hueDistance(a, b) {
- let diff = Math.abs(a - b);
- diff = Math.min(diff, 1 - diff);
- return diff;
-}
diff --git a/Image_editor_example/image_editor_modules/constants.js b/Image_editor_example/image_editor_modules/constants.js
deleted file mode 100644
index e74dc54..0000000
--- a/Image_editor_example/image_editor_modules/constants.js
+++ /dev/null
@@ -1,24 +0,0 @@
-export const ICONS = {
- crop: ` `,
- adjust: ` `,
- undo: ` `,
- redo: ` `,
- reset: ` `,
- chevronDown: ` `,
- close: ` `,
- flipH: ` `,
- flipV: ` `,
- rotate: ` `
-};
-
-export const HSL_COLORS = [
- { id: "red", label: "Red", color: "#ff4b4b", center: 0 / 360, width: 0.12 },
- { id: "orange", label: "Orange", color: "#ff884d", center: 30 / 360, width: 0.12 },
- { id: "yellow", label: "Yellow", color: "#ffd84d", center: 50 / 360, width: 0.12 },
- { id: "green", label: "Green", color: "#45d98e", center: 120 / 360, width: 0.12 },
- { id: "cyan", label: "Cyan", color: "#30c4ff", center: 180 / 360, width: 0.12 },
- { id: "blue", label: "Blue", color: "#2f7bff", center: 220 / 360, width: 0.12 },
- { id: "magenta", label: "Magenta", color: "#c95bff", center: 300 / 360, width: 0.12 }
-];
-
-export const IMAGE_EDITOR_SUBFOLDER = "image_editor";
diff --git a/Image_editor_example/image_editor_modules/curve.js b/Image_editor_example/image_editor_modules/curve.js
deleted file mode 100644
index eb4d7d8..0000000
--- a/Image_editor_example/image_editor_modules/curve.js
+++ /dev/null
@@ -1,493 +0,0 @@
-import { clamp01 } from "./color.js";
-
-const CHANNEL_COLORS = {
- rgb: "#ffffff",
- r: "#ff7070",
- g: "#70ffa0",
- b: "#72a0ff"
-};
-
-export class CurveEditor {
- constructor({ canvas, channelButtons = [], resetButton, onChange, onCommit }) {
- this.canvas = canvas;
- this.ctx = canvas.getContext("2d");
- this.channelButtons = channelButtons;
- this.resetButton = resetButton;
- this.onChange = onChange;
- this.onCommit = onCommit;
-
- this.channels = ["rgb", "r", "g", "b"];
- this.activeChannel = "rgb";
- this.curves = this.createDefaultCurves();
- this.curveTangents = {};
- this.channels.forEach(channel => (this.curveTangents[channel] = []));
- this.luts = this.buildAllLUTs();
- this.isDragging = false;
- this.dragIndex = null;
- this.curveDirty = false;
- this.displayWidth = this.canvas.clientWidth || 240;
- this.displayHeight = this.canvas.clientHeight || 240;
-
- this.resizeObserver = null;
- this.handleResize = this.handleResize.bind(this);
- this.onPointerDown = this.onPointerDown.bind(this);
- this.onPointerMove = this.onPointerMove.bind(this);
- this.onPointerUp = this.onPointerUp.bind(this);
- this.onDoubleClick = this.onDoubleClick.bind(this);
-
- window.addEventListener("resize", this.handleResize);
- this.canvas.addEventListener("mousedown", this.onPointerDown);
- window.addEventListener("mousemove", this.onPointerMove);
- window.addEventListener("mouseup", this.onPointerUp);
- this.canvas.addEventListener("dblclick", this.onDoubleClick);
-
- this.attachChannelButtons();
- this.attachResetButton();
- this.handleResize();
- if (window.ResizeObserver) {
- this.resizeObserver = new ResizeObserver(() => this.handleResize());
- this.resizeObserver.observe(this.canvas);
- }
- this.draw();
- }
-
- destroy() {
- this.resizeObserver?.disconnect();
- window.removeEventListener("resize", this.handleResize);
- this.canvas.removeEventListener("mousedown", this.onPointerDown);
- window.removeEventListener("mousemove", this.onPointerMove);
- window.removeEventListener("mouseup", this.onPointerUp);
- this.canvas.removeEventListener("dblclick", this.onDoubleClick);
- }
-
- attachChannelButtons() {
- this.channelButtons.forEach(btn => {
- btn.addEventListener("click", () => {
- const channel = btn.dataset.curveChannel;
- if (channel && this.channels.includes(channel)) {
- this.activeChannel = channel;
- this.updateChannelButtons();
- this.draw();
- }
- });
- });
- this.updateChannelButtons();
- }
-
- attachResetButton() {
- if (!this.resetButton) return;
- this.resetButton.addEventListener("click", () => {
- this.resetChannel(this.activeChannel);
- this.notifyChange();
- this.notifyCommit();
- });
- }
-
- updateChannelButtons() {
- this.channelButtons.forEach(btn => {
- const channel = btn.dataset.curveChannel;
- btn.classList.toggle("active", channel === this.activeChannel);
- });
- }
-
- handleResize() {
- const rect = this.canvas.getBoundingClientRect();
- const width = Math.max(1, rect.width || 240);
- const height = Math.max(1, rect.height || 240);
- this.displayWidth = width;
- this.displayHeight = height;
- const dpr = window.devicePixelRatio || 1;
- this.canvas.width = width * dpr;
- this.canvas.height = height * dpr;
- this.ctx.setTransform(1, 0, 0, 1, 0, 0);
- this.ctx.scale(dpr, dpr);
- this.draw();
- }
-
- createDefaultCurve() {
- return [
- { x: 0, y: 0 },
- { x: 1, y: 1 }
- ];
- }
-
- createDefaultCurves() {
- return {
- rgb: this.createDefaultCurve().map(p => ({ ...p })),
- r: this.createDefaultCurve().map(p => ({ ...p })),
- g: this.createDefaultCurve().map(p => ({ ...p })),
- b: this.createDefaultCurve().map(p => ({ ...p }))
- };
- }
-
- cloneCurves(source = this.curves) {
- const clone = {};
- this.channels.forEach(channel => {
- clone[channel] = (source[channel] || this.createDefaultCurve()).map(p => ({ x: p.x, y: p.y }));
- });
- return clone;
- }
-
- setState(state) {
- if (!state) return;
- const incoming = this.cloneCurves(state.curves || {});
- this.curves = incoming;
- if (state.activeChannel && this.channels.includes(state.activeChannel)) {
- this.activeChannel = state.activeChannel;
- }
- this.rebuildAllLUTs();
- this.updateChannelButtons();
- this.draw();
- }
-
- getState() {
- return {
- curves: this.cloneCurves(),
- activeChannel: this.activeChannel
- };
- }
-
- resetChannel(channel, emit = false) {
- if (!this.channels.includes(channel)) return;
- this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p }));
- this.rebuildChannelLUT(channel);
- this.draw();
- if (emit) {
- this.notifyChange();
- this.notifyCommit();
- this.curveDirty = false;
- }
- }
-
- resetAll(emit = true) {
- this.channels.forEach(channel => {
- this.curves[channel] = this.createDefaultCurve().map(p => ({ ...p }));
- });
- this.rebuildAllLUTs();
- this.draw();
- if (emit) {
- this.notifyChange();
- this.notifyCommit();
- this.curveDirty = false;
- }
- }
-
- hasAdjustments() {
- return this.channels.some(channel => !this.isDefaultCurve(this.curves[channel]));
- }
-
- isDefaultCurve(curve) {
- if (!curve || curve.length !== 2) return false;
- const [start, end] = curve;
- const epsilon = 0.0001;
- return Math.abs(start.x) < epsilon && Math.abs(start.y) < epsilon &&
- Math.abs(end.x - 1) < epsilon && Math.abs(end.y - 1) < epsilon;
- }
-
- notifyChange() {
- this.onChange?.();
- }
-
- notifyCommit() {
- this.onCommit?.();
- }
-
- getLUTPack() {
- return {
- rgb: this.isDefaultCurve(this.curves.rgb) ? null : this.luts.rgb,
- r: this.isDefaultCurve(this.curves.r) ? null : this.luts.r,
- g: this.isDefaultCurve(this.curves.g) ? null : this.luts.g,
- b: this.isDefaultCurve(this.curves.b) ? null : this.luts.b,
- hasAdjustments: this.hasAdjustments()
- };
- }
-
- buildAllLUTs() {
- const result = {};
- this.channels.forEach(channel => {
- const curve = this.curves[channel];
- const tangents = this.computeTangents(curve);
- this.curveTangents[channel] = tangents;
- result[channel] = this.buildCurveLUT(curve, tangents);
- });
- return result;
- }
-
- rebuildAllLUTs() {
- this.luts = this.buildAllLUTs();
- }
-
- rebuildChannelLUT(channel) {
- const curve = this.curves[channel];
- const tangents = this.computeTangents(curve);
- this.curveTangents[channel] = tangents;
- this.luts[channel] = this.buildCurveLUT(curve, tangents);
- }
-
- buildCurveLUT(curve, tangents = null) {
- const curveTangents = tangents || this.computeTangents(curve);
- const lut = new Uint8ClampedArray(256);
- for (let i = 0; i < 256; i++) {
- const pos = i / 255;
- lut[i] = Math.round(clamp01(this.sampleSmoothCurve(curve, pos, curveTangents)) * 255);
- }
- return lut;
- }
-
- computeTangents(curve) {
- const n = curve.length;
- if (n < 2) return new Array(n).fill(0);
- const tangents = new Array(n).fill(0);
- const delta = new Array(n - 1).fill(0);
- const dx = new Array(n - 1).fill(0);
- for (let i = 0; i < n - 1; i++) {
- dx[i] = Math.max(1e-6, curve[i + 1].x - curve[i].x);
- delta[i] = (curve[i + 1].y - curve[i].y) / dx[i];
- }
- tangents[0] = delta[0];
- tangents[n - 1] = delta[n - 2];
- for (let i = 1; i < n - 1; i++) {
- if (delta[i - 1] * delta[i] <= 0) {
- tangents[i] = 0;
- } else {
- const w1 = 2 * dx[i] + dx[i - 1];
- const w2 = dx[i] + 2 * dx[i - 1];
- tangents[i] = (w1 + w2) / (w1 / delta[i - 1] + w2 / delta[i]);
- }
- }
- for (let i = 0; i < n - 1; i++) {
- if (Math.abs(delta[i]) < 1e-6) {
- tangents[i] = 0;
- tangents[i + 1] = 0;
- } else {
- let alpha = tangents[i] / delta[i];
- let beta = tangents[i + 1] / delta[i];
- const sum = alpha * alpha + beta * beta;
- if (sum > 9) {
- const tau = 3 / Math.sqrt(sum);
- alpha *= tau;
- beta *= tau;
- tangents[i] = alpha * delta[i];
- tangents[i + 1] = beta * delta[i];
- }
- }
- }
- return tangents;
- }
-
- sampleSmoothCurve(curve, t, tangents) {
- if (!curve || curve.length === 0) return t;
- const n = curve.length;
- if (!tangents || tangents.length !== n) {
- tangents = this.computeTangents(curve);
- }
- if (t <= curve[0].x) return curve[0].y;
- if (t >= curve[n - 1].x) return curve[n - 1].y;
- let idx = 1;
- for (; idx < n; idx++) {
- if (t <= curve[idx].x) break;
- }
- const p0 = curve[idx - 1];
- const p1 = curve[idx];
- const m0 = tangents[idx - 1] ?? 0;
- const m1 = tangents[idx] ?? 0;
- const span = p1.x - p0.x || 1e-6;
- const u = (t - p0.x) / span;
- const h00 = (2 * u ** 3) - (3 * u ** 2) + 1;
- const h10 = u ** 3 - 2 * u ** 2 + u;
- const h01 = (-2 * u ** 3) + (3 * u ** 2);
- const h11 = u ** 3 - u ** 2;
- const value = h00 * p0.y + h10 * span * m0 + h01 * p1.y + h11 * span * m1;
- return clamp01(value);
- }
-
- getActiveCurve() {
- return this.curves[this.activeChannel];
- }
-
- addPoint(x, y) {
- const points = this.getActiveCurve();
- let insertIndex = points.findIndex(point => x < point.x);
- if (insertIndex === -1) {
- points.push({ x, y });
- insertIndex = points.length - 1;
- } else {
- points.splice(insertIndex, 0, { x, y });
- }
- this.rebuildChannelLUT(this.activeChannel);
- this.draw();
- this.curveDirty = true;
- this.notifyChange();
- return insertIndex;
- }
-
- updatePoint(index, x, y) {
- const points = this.getActiveCurve();
- const point = points[index];
- if (!point) return;
- const originalX = point.x;
- const originalY = point.y;
- if (index === 0) {
- point.x = 0;
- point.y = clamp01(y);
- } else if (index === points.length - 1) {
- point.x = 1;
- point.y = clamp01(y);
- } else {
- const minX = points[index - 1].x + 0.01;
- const maxX = points[index + 1].x - 0.01;
- point.x = clamp01(Math.min(Math.max(x, minX), maxX));
- point.y = clamp01(y);
- }
- if (Math.abs(originalX - point.x) < 0.0001 && Math.abs(originalY - point.y) < 0.0001) {
- return;
- }
- this.rebuildChannelLUT(this.activeChannel);
- this.draw();
- this.curveDirty = true;
- this.notifyChange();
- }
-
- removePoint(index) {
- const points = this.getActiveCurve();
- if (index <= 0 || index >= points.length - 1) return;
- points.splice(index, 1);
- this.rebuildChannelLUT(this.activeChannel);
- this.draw();
- this.notifyChange();
- this.notifyCommit();
- this.curveDirty = false;
- }
-
- getPointerPosition(event) {
- const rect = this.canvas.getBoundingClientRect();
- if (!rect.width || !rect.height) return null;
- const x = clamp01((event.clientX - rect.left) / rect.width);
- const y = clamp01(1 - (event.clientY - rect.top) / rect.height);
- return { x, y };
- }
-
- findPointIndex(pos, threshold = 10) {
- if (!pos) return -1;
- const points = this.getActiveCurve();
- const targetX = pos.x * this.displayWidth;
- const targetY = (1 - pos.y) * this.displayHeight;
- for (let i = 0; i < points.length; i++) {
- const pt = points[i];
- const px = pt.x * this.displayWidth;
- const py = (1 - pt.y) * this.displayHeight;
- const dist = Math.hypot(px - targetX, py - targetY);
- if (dist <= threshold) return i;
- }
- return -1;
- }
-
- onPointerDown(event) {
- if (event.button !== 0) return;
- const pos = this.getPointerPosition(event);
- if (!pos) return;
- event.preventDefault();
- let idx = this.findPointIndex(pos);
- if (idx === -1) {
- idx = this.addPoint(pos.x, pos.y);
- }
- this.dragIndex = idx;
- this.isDragging = true;
- this.updatePoint(idx, pos.x, pos.y);
- }
-
- onPointerMove(event) {
- if (!this.isDragging || this.dragIndex === null) return;
- const pos = this.getPointerPosition(event);
- if (!pos) return;
- event.preventDefault();
- this.updatePoint(this.dragIndex, pos.x, pos.y);
- }
-
- onPointerUp() {
- if (!this.isDragging) return;
- this.isDragging = false;
- this.dragIndex = null;
- if (this.curveDirty) {
- this.curveDirty = false;
- this.notifyCommit();
- }
- }
-
- onDoubleClick(event) {
- const pos = this.getPointerPosition(event);
- if (!pos) return;
- const idx = this.findPointIndex(pos, 8);
- if (idx > 0 && idx < this.getActiveCurve().length - 1) {
- this.removePoint(idx);
- }
- }
-
- getChannelColor() {
- return CHANNEL_COLORS[this.activeChannel] || "#ffffff";
- }
-
- draw() {
- if (!this.ctx) return;
- const ctx = this.ctx;
- const w = this.displayWidth;
- const h = this.displayHeight;
- ctx.clearRect(0, 0, w, h);
- this.drawGrid(ctx, w, h);
- this.drawCurve(ctx, w, h);
- this.drawPoints(ctx, w, h);
- }
-
- drawGrid(ctx, w, h) {
- ctx.fillStyle = "rgba(0,0,0,0.5)";
- ctx.fillRect(0, 0, w, h);
- ctx.strokeStyle = "rgba(255,255,255,0.08)";
- ctx.lineWidth = 1;
- for (let i = 1; i < 4; i++) {
- const x = (w / 4) * i;
- const y = (h / 4) * i;
- ctx.beginPath();
- ctx.moveTo(x, 0);
- ctx.lineTo(x, h);
- ctx.stroke();
- ctx.beginPath();
- ctx.moveTo(0, y);
- ctx.lineTo(w, y);
- ctx.stroke();
- }
- }
-
- drawCurve(ctx, w, h) {
- const points = this.getActiveCurve();
- if (!points?.length) return;
- const tangents = this.curveTangents[this.activeChannel] || this.computeTangents(points);
- ctx.strokeStyle = this.getChannelColor();
- ctx.lineWidth = 2;
- ctx.beginPath();
- const steps = 128;
- for (let i = 0; i <= steps; i++) {
- const t = i / steps;
- const value = this.sampleSmoothCurve(points, t, tangents);
- const x = t * w;
- const y = (1 - value) * h;
- if (i === 0) ctx.moveTo(x, y);
- else ctx.lineTo(x, y);
- }
- ctx.stroke();
- }
-
- drawPoints(ctx, w, h) {
- const points = this.getActiveCurve();
- ctx.fillStyle = "#000";
- ctx.lineWidth = 2;
- ctx.strokeStyle = this.getChannelColor();
- points.forEach(pt => {
- const x = pt.x * w;
- const y = (1 - pt.y) * h;
- ctx.beginPath();
- ctx.arc(x, y, 5, 0, Math.PI * 2);
- ctx.fill();
- ctx.stroke();
- });
- }
-}
diff --git a/Image_editor_example/image_editor_modules/editor.js b/Image_editor_example/image_editor_modules/editor.js
deleted file mode 100644
index 994941c..0000000
--- a/Image_editor_example/image_editor_modules/editor.js
+++ /dev/null
@@ -1,1287 +0,0 @@
-import { ICONS, HSL_COLORS } from './constants.js';
-import { clamp01, clamp255, rgbToHsl, hslToRgb, hueDistance } from './color.js';
-import { CurveEditor } from './curve.js';
-
-export class ImageEditor {
- constructor(imageSrc, saveCallback) {
- this.imageSrc = imageSrc;
- this.saveCallback = saveCallback;
-
- // State
- this.params = {
- exposure: 0, // -100 to 100
- contrast: 0, // -100 to 100
- saturation: 0, // -100 to 100
- temp: 0, // -100 to 100
- tint: 0, // -100 to 100
- vibrance: 0, // -100 to 100
- hue: 0, // -180 to 180
- highlight: 0, // -100 to 100
- shadow: 0, // -100 to 100
- blur: 0, // 0 to 100
- noise: 0, // 0 to 100
- grain: 0, // 0 to 100
- clarity: 0, // -100 to 100
- dehaze: 0, // -100 to 100
- hslHue: 0, // -180 to 180 (current selection)
- hslSaturation: 0, // -100 to 100 (current selection)
- hslLightness: 0 // -100 to 100 (current selection)
- };
- this.activeHSLColor = HSL_COLORS[0]?.id || null;
- this.hslAdjustments = this.getDefaultHSLAdjustments();
- this.curveEditor = null;
-
- this.history = [];
- this.historyIndex = -1;
-
- this.zoom = 1;
- this.pan = { x: 0, y: 0 };
- this.isDragging = false;
- this.lastMousePos = { x: 0, y: 0 };
-
- this.isCropping = false;
- this.cropStart = null;
- this.cropRect = null;
- this.activeHandle = null;
-
- this.createUI();
- this.loadImage();
- }
-
- createUI() {
- this.overlay = document.createElement("div");
- this.overlay.className = "apix-overlay";
-
- this.overlay.innerHTML = `
-
-
-
-
-
-
-
-
-
-
- -
- 100%
- +
- Fit
-
-
-
-
-
- `;
-
- document.body.appendChild(this.overlay);
- this.bindEvents();
- }
-
- renderSlider(label, id, min, max, val) {
- return `
-
-
-
${label}
-
- ${val}
-
-
-
-
- ${ICONS.reset}
-
-
- `;
- }
-
- renderCurvePanel() {
- return `
-
- `;
- }
-
- renderHSLSection() {
- if (!this.activeHSLColor && HSL_COLORS.length) {
- this.activeHSLColor = HSL_COLORS[0].id;
- }
- const swatches = HSL_COLORS.map(color => `
-
- `).join("");
- return `
-
-
${swatches}
- ${this.renderHSLSlider("Hue", "h", -180, 180, this.params.hslHue)}
- ${this.renderHSLSlider("Saturation", "s", -100, 100, this.params.hslSaturation)}
- ${this.renderHSLSlider("Luminance", "l", -100, 100, this.params.hslLightness)}
-
- ${this.getActiveHSLLabel()}
- Reset
-
-
- `;
- }
-
- renderHSLSlider(label, key, min, max, val) {
- return `
-
-
-
${label}
-
- ${val}
-
-
-
-
- ${ICONS.reset}
-
-
- `;
- }
-
- getActiveHSLLabel() {
- const active = HSL_COLORS.find(c => c.id === this.activeHSLColor);
- return active ? active.label : (HSL_COLORS[0]?.label || "Color");
- }
-
- bindEvents() {
- this.canvas = this.overlay.querySelector("#editor-canvas");
- this.ctx = this.canvas.getContext("2d");
- this.container = this.overlay.querySelector("#canvas-container");
- this.cropBox = this.overlay.querySelector("#crop-box");
-
- // Sliders
- const hslKeys = new Set(["hslHue", "hslSaturation", "hslLightness"]);
- Object.keys(this.params).forEach(key => {
- if (hslKeys.has(key)) return;
- const slider = this.overlay.querySelector(`#param-${key}`);
- if (!slider) return;
- slider.oninput = (e) => {
- const val = parseFloat(e.target.value);
- this.params[key] = val;
- const display = this.overlay.querySelector(`#val-${key}`);
- if (display) display.textContent = val;
- this.requestRender();
- };
- slider.onchange = () => this.pushHistory(); // Save state on release
- });
- this.bindHSLControls();
- this.initCurveEditor();
-
- // Accordions
- this.overlay.querySelectorAll(".apix-panel-header").forEach(header => {
- header.onclick = () => {
- const targetId = header.dataset.target;
- const content = this.overlay.querySelector(`#${targetId}`);
- const isHidden = content.classList.contains("hidden");
- // Close all first (optional, mimicking accordion)
- // this.overlay.querySelectorAll(".apix-panel-content").forEach(c => c.classList.add("hidden"));
- content.classList.toggle("hidden", !isHidden);
- header.querySelector("svg").style.transform = isHidden ? "rotate(180deg)" : "rotate(0deg)";
- };
- });
-
- // Tools
- this.overlay.querySelector("#tool-crop").onclick = () => this.toggleMode('crop');
- this.overlay.querySelector("#tool-adjust").onclick = () => this.toggleMode('adjust');
-
- // Crop Actions
- this.overlay.querySelector("#crop-apply").onclick = () => this.applyCrop();
- this.overlay.querySelector("#crop-cancel").onclick = () => this.toggleMode('adjust');
-
- // Zoom/Pan
- this.container.addEventListener('wheel', (e) => {
- e.preventDefault();
- const delta = e.deltaY > 0 ? 0.9 : 1.1;
- this.zoom = Math.max(0.1, Math.min(10, this.zoom * delta));
- this.updateZoomDisplay();
- this.requestRender();
- });
-
- this.container.addEventListener('mousedown', (e) => {
- if (this.isCropping) {
- this.handleCropStart(e);
- } else {
- this.isDragging = true;
- this.lastMousePos = { x: e.clientX, y: e.clientY };
- this.container.style.cursor = 'grabbing';
- }
- });
-
- window.addEventListener('mousemove', (e) => {
- if (this.isDragging) {
- const dx = e.clientX - this.lastMousePos.x;
- const dy = e.clientY - this.lastMousePos.y;
- this.pan.x += dx;
- this.pan.y += dy;
- this.lastMousePos = { x: e.clientX, y: e.clientY };
- this.requestRender();
- } else if (this.isCropping) {
- this.handleCropMove(e);
- }
- });
-
- window.addEventListener('mouseup', () => {
- this.isDragging = false;
- this.container.style.cursor = this.isCropping ? 'crosshair' : 'grab';
- if (this.isCropping) this.handleCropEnd();
- });
-
- // Zoom Buttons
- this.overlay.querySelector("#zoom-in").onclick = () => { this.zoom *= 1.2; this.updateZoomDisplay(); this.requestRender(); };
- this.overlay.querySelector("#zoom-out").onclick = () => { this.zoom /= 1.2; this.updateZoomDisplay(); this.requestRender(); };
- this.overlay.querySelector("#zoom-fit").onclick = () => this.fitCanvas();
-
- // Transform buttons
- this.overlay.querySelector("#flip-btn-horizontal").onclick = () => this.flipImage("horizontal");
- this.overlay.querySelector("#flip-btn-vertical").onclick = () => this.flipImage("vertical");
- this.overlay.querySelector("#rotate-btn-90").onclick = () => this.rotateImage(90);
-
- // Main Actions
- this.overlay.querySelector("#action-close").onclick = () => this.close();
- this.overlay.querySelector("#action-save").onclick = () => this.save();
- this.overlay.querySelector("#action-download").onclick = () => this.download();
- this.overlay.querySelector("#action-reset").onclick = () => this.reset();
- this.overlay.querySelector("#action-undo").onclick = () => this.undo();
- this.overlay.querySelector("#action-redo").onclick = () => this.redo();
- }
-
- bindHSLControls() {
- if (!this.overlay) return;
- this.overlay.querySelectorAll(".apix-hsl-chip").forEach(btn => {
- btn.onclick = () => {
- this.activeHSLColor = btn.dataset.color;
- this.syncHSLSliders();
- this.updateHSLUI();
- };
- });
-
- const hslMap = { h: "hslHue", s: "hslSaturation", l: "hslLightness" };
- ["h", "s", "l"].forEach(key => {
- const slider = this.overlay.querySelector(`#hsl-slider-${key}`);
- if (!slider) return;
- slider.oninput = (e) => {
- const val = parseFloat(e.target.value);
- const current = this.hslAdjustments[this.activeHSLColor];
- current[key] = val;
- this.params[hslMap[key]] = val;
- const label = this.overlay.querySelector(`#val-hsl-${key}`);
- if (label) label.textContent = val;
- this.requestRender();
- };
- slider.onchange = () => this.pushHistory();
- });
-
- const resetBtn = this.overlay.querySelector("#hsl-reset");
- if (resetBtn) {
- resetBtn.onclick = () => {
- this.resetCurrentHSL();
- this.pushHistory();
- };
- }
-
- this.syncHSLSliders();
- this.updateHSLUI();
- this.bindSliderResetButtons();
- }
-
- initCurveEditor() {
- if (!this.overlay) return;
- const canvas = this.overlay.querySelector("#curve-canvas");
- if (!canvas) return;
- const channelButtons = Array.from(this.overlay.querySelectorAll(".apix-curve-channel-btn"));
- const resetBtn = this.overlay.querySelector("#curve-reset");
- this.curveEditor = new CurveEditor({
- canvas,
- channelButtons,
- resetButton: resetBtn,
- onChange: () => this.requestRender(),
- onCommit: () => this.pushHistory()
- });
- }
-
- resetCurrentHSL() {
- const current = this.hslAdjustments[this.activeHSLColor];
- if (!current) return;
- current.h = 0;
- current.s = 0;
- current.l = 0;
- this.syncHSLSliders();
- this.requestRender();
- }
-
- syncHSLSliders() {
- const current = this.hslAdjustments[this.activeHSLColor];
- if (!current || !this.overlay) return;
- const map = { h: "hslHue", s: "hslSaturation", l: "hslLightness" };
- ["h", "s", "l"].forEach(key => {
- const slider = this.overlay.querySelector(`#hsl-slider-${key}`);
- if (slider) slider.value = current[key];
- const label = this.overlay.querySelector(`#val-hsl-${key}`);
- if (label) label.textContent = current[key];
- this.params[map[key]] = current[key];
- });
- }
-
- updateHSLUI() {
- if (!this.overlay) return;
- this.overlay.querySelectorAll(".apix-hsl-chip").forEach(btn => {
- btn.classList.toggle("active", btn.dataset.color === this.activeHSLColor);
- });
- const label = this.overlay.querySelector("#hsl-active-label");
- if (label) label.textContent = this.getActiveHSLLabel();
- }
-
- bindSliderResetButtons() {
- if (!this.overlay) return;
- const hslMap = { h: "hslHue", s: "hslSaturation", l: "hslLightness" };
- this.overlay.querySelectorAll(".apix-slider-reset").forEach(btn => {
- const sliderId = btn.dataset.slider;
- const slider = this.overlay.querySelector(`#${sliderId}`);
- if (!slider) return;
- const defaultVal = parseFloat(btn.dataset.default ?? "0");
- const isHSL = btn.dataset.hsl === "true";
- btn.onclick = () => {
- slider.value = defaultVal;
- if (!isHSL) {
- const paramKey = sliderId.replace("param-", "");
- if (this.params.hasOwnProperty(paramKey)) {
- this.params[paramKey] = defaultVal;
- }
- const valueLabel = this.overlay.querySelector(`#val-${paramKey}`);
- if (valueLabel) valueLabel.textContent = defaultVal;
- } else {
- const key = btn.dataset.hslKey;
- const current = this.hslAdjustments[this.activeHSLColor];
- if (current) {
- current[key] = defaultVal;
- }
- if (hslMap[key]) {
- this.params[hslMap[key]] = defaultVal;
- }
- const display = this.overlay.querySelector(`#val-hsl-${key}`);
- if (display) display.textContent = defaultVal;
- }
- this.requestRender();
- this.pushHistory();
- };
- });
- }
-
- cloneHSLAdjustments(source = this.hslAdjustments) {
- const clone = {};
- Object.keys(source || {}).forEach(key => {
- clone[key] = { ...(source[key] || { h: 0, s: 0, l: 0 }) };
- });
- return clone;
- }
-
- getDefaultHSLAdjustments() {
- const defaults = {};
- HSL_COLORS.forEach(color => {
- defaults[color.id] = { h: 0, s: 0, l: 0 };
- });
- return defaults;
- }
-
- hasHSLAdjustments() {
- return Object.keys(this.hslAdjustments || {}).some(key => {
- const adj = this.hslAdjustments[key];
- return adj && (adj.h || adj.s || adj.l);
- });
- }
-
- shouldApplyPixelEffects() {
- const p = this.params;
- const totalNoise = Math.max(0, (p.noise || 0) + (p.grain || 0));
- return totalNoise > 0 || this.hasHSLAdjustments() || p.clarity !== 0 || p.dehaze !== 0 || p.highlight !== 0 || p.shadow !== 0 || p.vibrance !== 0 || (this.curveEditor?.hasAdjustments() ?? false);
- }
-
- loadImage() {
- this.originalImage = new Image();
- this.originalImage.onload = () => {
- this.currentImage = this.originalImage;
- this.fitCanvas();
- this.syncHSLSliders();
- this.updateHSLUI();
- this.pushHistory(); // Initial state
- };
- this.originalImage.src = this.imageSrc;
- }
-
- fitCanvas() {
- if (!this.currentImage) return;
- const containerW = this.container.clientWidth - 40;
- const containerH = this.container.clientHeight - 40;
- const scale = Math.min(containerW / this.currentImage.width, containerH / this.currentImage.height);
- this.zoom = scale;
- this.pan = { x: 0, y: 0 }; // Center
- this.updateZoomDisplay();
- this.requestRender();
- }
-
- updateZoomDisplay() {
- this.overlay.querySelector("#zoom-level").textContent = Math.round(this.zoom * 100) + "%";
- }
-
- toggleMode(mode) {
- this.isCropping = mode === 'crop';
-
- // Update UI
- this.overlay.querySelectorAll(".apix-mode-btn").forEach(b => b.classList.remove("active"));
- this.overlay.querySelector(`#tool-${mode}`).classList.add("active");
-
- // Show/Hide Crop Controls
- const cropPanel = this.overlay.querySelector("#panel-crop-controls");
- // FIXED: Do not hide parent element, just toggle the panel content
-
- if (mode === 'crop') {
- cropPanel.classList.remove("hidden");
- cropPanel.scrollIntoView({ behavior: 'smooth' });
- } else {
- cropPanel.classList.add("hidden");
- }
-
- this.container.style.cursor = this.isCropping ? 'crosshair' : 'grab';
- this.cropBox.style.display = 'none';
- this.cropStart = null;
- this.cropRect = null;
- }
-
- // --- Rendering ---
-
- requestRender() {
- if (!this.renderRequested) {
- this.renderRequested = true;
- requestAnimationFrame(() => {
- this.render();
- this.renderRequested = false;
- });
- }
- }
-
- render() {
- if (!this.currentImage) return;
-
- const w = this.container.clientWidth;
- const h = this.container.clientHeight;
- this.canvas.width = w;
- this.canvas.height = h;
-
- // Clear
- this.ctx.clearRect(0, 0, w, h);
-
- // Calculate transformed position
- const imgW = this.currentImage.width * this.zoom;
- const imgH = this.currentImage.height * this.zoom;
- const centerX = w / 2 + this.pan.x;
- const centerY = h / 2 + this.pan.y;
- const x = centerX - imgW / 2;
- const y = centerY - imgH / 2;
-
- // Save context for transforms
- this.ctx.save();
-
- // 1. Apply Filters (CSS style for preview performance)
- // Note: Canvas filter API is widely supported now
- const p = this.params;
- const clarityBoost = 1 + (p.clarity || 0) / 200;
- const dehazeBoost = 1 + Math.max(0, p.dehaze || 0) / 200;
- const brightness = 100 + p.exposure;
- const contrast = Math.max(0, (100 + p.contrast) * clarityBoost * dehazeBoost);
- let saturate = 100 + p.saturation;
- if (p.dehaze > 0) {
- saturate += p.dehaze * 0.3;
- }
- const hue = p.hue;
- const blur = p.blur / 5; // Scale down
-
- let filterString = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturate}%) hue-rotate(${hue}deg)`;
- if (blur > 0) filterString += ` blur(${blur}px)`;
-
- this.ctx.filter = filterString;
-
- const drawX = x;
- const drawY = y;
- const drawW = imgW;
- const drawH = imgH;
- this.ctx.drawImage(this.currentImage, drawX, drawY, drawW, drawH);
- this.ctx.filter = 'none';
-
- const rect = { x: drawX, y: drawY, width: drawW, height: drawH };
-
- // 2. Overlays (Temp/Tint)
- if (p.temp !== 0 || p.tint !== 0) {
- this.ctx.globalCompositeOperation = 'overlay';
-
- // Temp (Blue/Orange)
- if (p.temp !== 0) {
- this.ctx.fillStyle = p.temp > 0 ? `rgba(255, 160, 0, ${p.temp / 200})` : `rgba(0, 100, 255, ${Math.abs(p.temp) / 200})`;
- this.ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
- }
-
- // Tint (Green/Magenta)
- if (p.tint !== 0) {
- this.ctx.fillStyle = p.tint > 0 ? `rgba(255, 0, 255, ${p.tint / 200})` : `rgba(0, 255, 0, ${Math.abs(p.tint) / 200})`;
- this.ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
- }
-
- this.ctx.globalCompositeOperation = 'source-over';
- }
-
- if (this.shouldApplyPixelEffects()) {
- this.applyPixelEffectsRegion(this.ctx, rect.x, rect.y, rect.width, rect.height);
- }
-
- this.ctx.restore();
- }
-
- flipImage(direction) {
- if (!this.currentImage) return;
- const canvas = document.createElement("canvas");
- canvas.width = this.currentImage.width;
- canvas.height = this.currentImage.height;
- const ctx = canvas.getContext("2d");
- ctx.save();
- if (direction === "horizontal") {
- ctx.translate(canvas.width, 0);
- ctx.scale(-1, 1);
- } else {
- ctx.translate(0, canvas.height);
- ctx.scale(1, -1);
- }
- ctx.drawImage(this.currentImage, 0, 0);
- ctx.restore();
-
- const flipped = new Image();
- flipped.onload = () => {
- this.currentImage = flipped;
- this.requestRender();
- this.pushHistory();
- };
- flipped.src = canvas.toDataURL();
- }
-
- rotateImage(angle = 90) {
- if (!this.currentImage) return;
- let normalized = angle % 360;
- if (normalized < 0) normalized += 360;
- if (normalized === 0) return;
-
- const imgW = this.currentImage.width;
- const imgH = this.currentImage.height;
- const needsSwap = normalized === 90 || normalized === 270;
-
- const canvas = document.createElement("canvas");
- canvas.width = needsSwap ? imgH : imgW;
- canvas.height = needsSwap ? imgW : imgH;
- const ctx = canvas.getContext("2d");
- ctx.save();
-
- if (normalized === 90) {
- ctx.translate(canvas.width, 0);
- ctx.rotate(Math.PI / 2);
- ctx.drawImage(this.currentImage, 0, 0);
- } else if (normalized === 180) {
- ctx.translate(canvas.width, canvas.height);
- ctx.rotate(Math.PI);
- ctx.drawImage(this.currentImage, 0, 0);
- } else if (normalized === 270) {
- ctx.translate(0, canvas.height);
- ctx.rotate(-Math.PI / 2);
- ctx.drawImage(this.currentImage, 0, 0);
- } else {
- ctx.translate(canvas.width / 2, canvas.height / 2);
- ctx.rotate((Math.PI / 180) * normalized);
- ctx.drawImage(this.currentImage, -imgW / 2, -imgH / 2);
- }
-
- ctx.restore();
-
- const rotated = new Image();
- rotated.onload = () => {
- this.currentImage = rotated;
- this.requestRender();
- this.pushHistory();
- };
- rotated.src = canvas.toDataURL();
- }
-
- applyPixelEffectsRegion(ctx, x, y, width, height) {
- const p = this.params;
- const totalNoise = Math.max(0, (p.noise || 0) + (p.grain || 0));
- const needsProcessing = totalNoise > 0 || this.hasHSLAdjustments() || p.clarity !== 0 || p.dehaze !== 0 || p.highlight !== 0 || p.shadow !== 0 || p.vibrance !== 0 || (this.curveEditor?.hasAdjustments() ?? false);
- if (!needsProcessing) return;
- if (width <= 0 || height <= 0) return;
-
- const startX = Math.max(0, Math.floor(x));
- const startY = Math.max(0, Math.floor(y));
- const endX = Math.min(ctx.canvas.width, Math.ceil(x + width));
- const endY = Math.min(ctx.canvas.height, Math.ceil(y + height));
- const regionW = endX - startX;
- const regionH = endY - startY;
- if (regionW <= 0 || regionH <= 0) return;
-
- let imageData;
- try {
- imageData = ctx.getImageData(startX, startY, regionW, regionH);
- } catch (err) {
- console.warn("ImageEditor: unable to read pixels for adjustments", err);
- return;
- }
-
- const data = imageData.data;
- const curvePack = this.curveEditor?.getLUTPack?.();
- const curvesActive = curvePack?.hasAdjustments;
- const curveRGB = curvesActive ? curvePack.rgb : null;
- const curveR = curvesActive ? curvePack.r : null;
- const curveG = curvesActive ? curvePack.g : null;
- const curveB = curvesActive ? curvePack.b : null;
- const clarityStrength = (p.clarity || 0) / 200;
- const dehazeStrength = (p.dehaze || 0) / 200;
- const highlightStrength = (p.highlight || 0) / 100;
- const shadowStrength = (p.shadow || 0) / 100;
- const noiseStrength = totalNoise / 100 * 30;
- const vibranceStrength = (p.vibrance || 0) / 100;
- const applyVibrance = vibranceStrength !== 0;
-
- for (let i = 0; i < data.length; i += 4) {
- let r = data[i];
- let g = data[i + 1];
- let b = data[i + 2];
- let { h, s, l } = rgbToHsl(r, g, b);
-
- const adjustment = this.getHSLAdjustmentForHue(h);
- const hueShift = (adjustment.h || 0) / 360;
- const satAdjust = (adjustment.s || 0) / 100;
- const lightAdjust = (adjustment.l || 0) / 100;
-
- if (hueShift) {
- h = (h + hueShift) % 1;
- if (h < 0) h += 1;
- }
- if (satAdjust) {
- if (satAdjust > 0) {
- s = clamp01(s + (1 - s) * satAdjust);
- } else {
- s = clamp01(s + s * satAdjust);
- }
- }
- if (lightAdjust) {
- if (lightAdjust > 0) {
- l = clamp01(l + (1 - l) * lightAdjust);
- } else {
- l = clamp01(l + l * lightAdjust);
- }
- }
- if (clarityStrength) {
- const delta = (l - 0.5) * clarityStrength;
- l = clamp01(l + delta);
- }
- if (dehazeStrength) {
- if (dehazeStrength > 0) {
- l = clamp01(l - (l - 0.4) * Math.abs(dehazeStrength));
- s = clamp01(s + (1 - s) * Math.abs(dehazeStrength) * 0.8);
- } else {
- const haze = Math.abs(dehazeStrength);
- l = clamp01(l + (1 - l) * haze * 0.5);
- s = clamp01(s - s * haze * 0.5);
- }
- }
- if (highlightStrength && l > 0.5) {
- const influence = (l - 0.5) * 2;
- l = clamp01(l + influence * highlightStrength);
- }
- if (shadowStrength && l < 0.5) {
- const influence = (0.5 - l) * 2;
- l = clamp01(l + influence * shadowStrength);
- }
- if (applyVibrance) {
- const midToneFactor = 0.5 + (1 - Math.abs(2 * l - 1)) * 0.5;
- if (vibranceStrength > 0) {
- s = clamp01(s + (1 - s) * vibranceStrength * midToneFactor);
- } else {
- s = clamp01(s + s * vibranceStrength * 0.8);
- }
- }
-
- ({ r, g, b } = hslToRgb(h, s, l));
-
- if (curvesActive) {
- if (curveR) r = curveR[r];
- if (curveG) g = curveG[g];
- if (curveB) b = curveB[b];
- if (curveRGB) {
- r = curveRGB[r];
- g = curveRGB[g];
- b = curveRGB[b];
- }
- }
-
- if (noiseStrength > 0) {
- const rand = (Math.random() - 0.5) * 2 * noiseStrength;
- r = clamp255(r + rand);
- g = clamp255(g + rand);
- b = clamp255(b + rand);
- }
-
- data[i] = r;
- data[i + 1] = g;
- data[i + 2] = b;
- }
-
- ctx.putImageData(imageData, startX, startY);
- }
-
- getHSLAdjustmentForHue(hueValue) {
- const adjustments = this.hslAdjustments || {};
- const result = { h: 0, s: 0, l: 0 };
-
- HSL_COLORS.forEach(color => {
- const adj = adjustments[color.id];
- if (!adj || color.center === null) return;
- const dist = hueDistance(hueValue, color.center);
- const width = color.width || 0.08;
- const maxDist = width * 2;
- if (dist >= maxDist) return;
- const normalized = dist / width;
- const influence = Math.exp(-normalized * normalized * 1.5);
- if (influence <= 0) return;
- result.h += adj.h * influence;
- result.s += adj.s * influence;
- result.l += adj.l * influence;
- });
-
- return result;
- }
-
- // --- Crop Logic ---
- handleCropStart(e) {
- const rect = this.container.getBoundingClientRect();
- const clientX = e.clientX - rect.left;
- const clientY = e.clientY - rect.top;
-
- // Check if clicking on a handle
- if (e.target.classList.contains('apix-crop-handle')) {
- this.activeHandle = e.target.dataset.handle;
- this.cropStart = { x: clientX, y: clientY }; // Reference for drag
- // Store initial rect state for resizing
- const style = window.getComputedStyle(this.cropBox);
- this.initialCropRect = {
- left: parseFloat(style.left),
- top: parseFloat(style.top),
- width: parseFloat(style.width),
- height: parseFloat(style.height)
- };
- return;
- }
-
- // Check if clicking inside existing crop box (Move)
- if (this.cropRect) {
- const style = window.getComputedStyle(this.cropBox);
- const left = parseFloat(style.left);
- const top = parseFloat(style.top);
- const width = parseFloat(style.width);
- const height = parseFloat(style.height);
-
- if (clientX >= left && clientX <= left + width && clientY >= top && clientY <= top + height) {
- this.activeHandle = 'move';
- this.cropStart = { x: clientX, y: clientY };
- this.initialCropRect = { left, top, width, height };
- return;
- }
- }
-
- // Start new crop
- // Convert to image coordinates to check bounds
- const w = this.container.clientWidth;
- const h = this.container.clientHeight;
- const imgW = this.currentImage.width * this.zoom;
- const imgH = this.currentImage.height * this.zoom;
- const centerX = w / 2 + this.pan.x;
- const centerY = h / 2 + this.pan.y;
- const imgX = centerX - imgW / 2;
- const imgY = centerY - imgH / 2;
-
- // Check if click is within image
- if (clientX < imgX || clientX > imgX + imgW || clientY < imgY || clientY > imgY + imgH) return;
-
- this.cropStart = { x: clientX, y: clientY };
- this.cropBox.style.display = 'block';
- this.activeHandle = 'new';
- this.updateCropBox(clientX, clientY, 0, 0);
- }
-
- handleCropMove(e) {
- if (!this.cropStart) return;
-
- const rect = this.container.getBoundingClientRect();
- const clientX = e.clientX - rect.left;
- const clientY = e.clientY - rect.top;
-
- if (this.activeHandle === 'new') {
- const w = clientX - this.cropStart.x;
- const h = clientY - this.cropStart.y;
- this.updateCropBox(this.cropStart.x, this.cropStart.y, w, h);
- } else if (this.activeHandle === 'move') {
- const dx = clientX - this.cropStart.x;
- const dy = clientY - this.cropStart.y;
- this.cropBox.style.left = (this.initialCropRect.left + dx) + 'px';
- this.cropBox.style.top = (this.initialCropRect.top + dy) + 'px';
- } else if (this.activeHandle) {
- // Resize logic
- const dx = clientX - this.cropStart.x;
- const dy = clientY - this.cropStart.y;
- let newLeft = this.initialCropRect.left;
- let newTop = this.initialCropRect.top;
- let newWidth = this.initialCropRect.width;
- let newHeight = this.initialCropRect.height;
-
- if (this.activeHandle.includes('l')) {
- newLeft += dx;
- newWidth -= dx;
- }
- if (this.activeHandle.includes('r')) {
- newWidth += dx;
- }
- if (this.activeHandle.includes('t')) {
- newTop += dy;
- newHeight -= dy;
- }
- if (this.activeHandle.includes('b')) {
- newHeight += dy;
- }
-
- // Enforce Aspect Ratio if set
- const aspectSelect = this.overlay.querySelector("#crop-aspect");
- if (aspectSelect.value !== 'free') {
- const ratio = parseFloat(aspectSelect.value);
- // Simple aspect enforcement (width dominant)
- if (this.activeHandle.includes('l') || this.activeHandle.includes('r')) {
- newHeight = newWidth / ratio;
- } else {
- newWidth = newHeight * ratio;
- }
- }
-
- if (newWidth > 10 && newHeight > 10) {
- this.cropBox.style.left = newLeft + 'px';
- this.cropBox.style.top = newTop + 'px';
- this.cropBox.style.width = newWidth + 'px';
- this.cropBox.style.height = newHeight + 'px';
- }
- }
- }
-
- handleCropEnd() {
- // Finalize crop box dimensions
- const style = window.getComputedStyle(this.cropBox);
- this.cropRect = {
- x: parseFloat(style.left),
- y: parseFloat(style.top),
- w: parseFloat(style.width),
- h: parseFloat(style.height)
- };
- this.cropStart = null;
- this.activeHandle = null;
- }
-
- updateCropBox(x, y, w, h) {
- let left = w < 0 ? x + w : x;
- let top = h < 0 ? y + h : y;
- let width = Math.abs(w);
- let height = Math.abs(h);
-
- // Constrain to aspect ratio if selected
- const aspectSelect = this.overlay.querySelector("#crop-aspect");
- if (aspectSelect.value !== 'free') {
- const ratio = parseFloat(aspectSelect.value);
- if (width / height > ratio) {
- width = height * ratio;
- } else {
- height = width / ratio;
- }
- }
-
- this.cropBox.style.left = left + 'px';
- this.cropBox.style.top = top + 'px';
- this.cropBox.style.width = width + 'px';
- this.cropBox.style.height = height + 'px';
- }
-
- applyCrop() {
- if (!this.cropRect || this.cropRect.w < 10) return;
-
- // Convert screen coords to image coords
- const w = this.container.clientWidth;
- const h = this.container.clientHeight;
- const imgW = this.currentImage.width * this.zoom;
- const imgH = this.currentImage.height * this.zoom;
- const centerX = w / 2 + this.pan.x;
- const centerY = h / 2 + this.pan.y;
- const imgX = centerX - imgW / 2;
- const imgY = centerY - imgH / 2;
-
- const relativeX = (this.cropRect.x - imgX) / this.zoom;
- const relativeY = (this.cropRect.y - imgY) / this.zoom;
- const relativeW = this.cropRect.w / this.zoom;
- const relativeH = this.cropRect.h / this.zoom;
-
- // Create new cropped image
- const canvas = document.createElement('canvas');
- canvas.width = relativeW;
- canvas.height = relativeH;
- const ctx = canvas.getContext('2d');
- ctx.drawImage(this.currentImage, relativeX, relativeY, relativeW, relativeH, 0, 0, relativeW, relativeH);
-
- const newImg = new Image();
- newImg.onload = () => {
- this.currentImage = newImg;
- this.toggleMode('adjust');
- this.fitCanvas();
- this.pushHistory();
- };
- newImg.src = canvas.toDataURL();
- }
-
- // --- History ---
- pushHistory() {
- // Remove future states if we are in middle of stack
- if (this.historyIndex < this.history.length - 1) {
- this.history = this.history.slice(0, this.historyIndex + 1);
- }
-
- // Save state
- this.history.push({
- params: { ...this.params },
- hslAdjustments: this.cloneHSLAdjustments(),
- activeHSLColor: this.activeHSLColor,
- curves: this.curveEditor ? this.curveEditor.getState() : null,
- image: this.currentImage.src // Save image source (base64) if changed by crop
- });
- this.historyIndex++;
- this.updateHistoryButtons();
- }
-
- undo() {
- if (this.historyIndex > 0) {
- this.historyIndex--;
- this.restoreState(this.history[this.historyIndex]);
- }
- }
-
- redo() {
- if (this.historyIndex < this.history.length - 1) {
- this.historyIndex++;
- this.restoreState(this.history[this.historyIndex]);
- }
- }
-
- restoreState(state) {
- this.params = { ...state.params };
- this.hslAdjustments = state.hslAdjustments ? this.cloneHSLAdjustments(state.hslAdjustments) : this.getDefaultHSLAdjustments();
- this.activeHSLColor = state.activeHSLColor || HSL_COLORS[0]?.id || null;
- this.syncHSLSliders();
- this.updateHSLUI();
- if (this.curveEditor) {
- if (state.curves) {
- this.curveEditor.setState(state.curves);
- } else {
- this.curveEditor.resetAll(false);
- }
- }
- // Update UI
- Object.keys(this.params).forEach(key => {
- const el = this.overlay.querySelector(`#param-${key}`);
- if (el) {
- el.value = this.params[key];
- this.overlay.querySelector(`#val-${key}`).textContent = this.params[key];
- }
- });
-
- // Update Image if changed (crop)
- if (state.image !== this.currentImage.src) {
- const img = new Image();
- img.onload = () => {
- this.currentImage = img;
- this.requestRender();
- };
- img.src = state.image;
- } else {
- this.requestRender();
- }
- this.updateHistoryButtons();
- }
-
- updateHistoryButtons() {
- this.overlay.querySelector("#action-undo").disabled = this.historyIndex <= 0;
- this.overlay.querySelector("#action-redo").disabled = this.historyIndex >= this.history.length - 1;
- }
-
- reset() {
- // Reset to initial state (index 0)
- if (this.history.length > 0) {
- this.historyIndex = 0;
- this.restoreState(this.history[0]);
- // Clear future
- this.history = [this.history[0]];
- this.updateHistoryButtons();
- }
- }
-
- async renderEditedBlob() {
- // 1. Create a high-res canvas
- const canvas = document.createElement("canvas");
- canvas.width = this.currentImage.width;
- canvas.height = this.currentImage.height;
- const ctx = canvas.getContext("2d");
-
- // 2. Apply filters
- const p = this.params;
- const clarityBoost = 1 + (p.clarity || 0) / 200;
- const dehazeBoost = 1 + Math.max(0, p.dehaze || 0) / 200;
- const brightness = 100 + p.exposure;
- const contrast = Math.max(0, (100 + p.contrast) * clarityBoost * dehazeBoost);
- let saturate = 100 + p.saturation;
- if (p.dehaze > 0) {
- saturate += p.dehaze * 0.3;
- }
- const hue = p.hue;
- const blur = p.blur / 5; // Scale appropriately for full res?
- // Note: CSS blur is px based, canvas filter blur is also px based.
- // If image is large, blur needs to be scaled up to look same as preview.
- // Preview zoom = this.zoom.
- // Real blur = p.blur / 5 / this.zoom (approx)
-
- let filterString = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturate}%) hue-rotate(${hue}deg)`;
- if (blur > 0) filterString += ` blur(${blur}px)`;
-
- ctx.filter = filterString;
- ctx.drawImage(this.currentImage, 0, 0);
- ctx.filter = 'none';
-
- // 3. Apply Overlays
- if (p.temp !== 0 || p.tint !== 0) {
- ctx.globalCompositeOperation = 'overlay';
- if (p.temp !== 0) {
- ctx.fillStyle = p.temp > 0 ? `rgba(255, 160, 0, ${p.temp / 200})` : `rgba(0, 100, 255, ${Math.abs(p.temp) / 200})`;
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- }
- if (p.tint !== 0) {
- ctx.fillStyle = p.tint > 0 ? `rgba(255, 0, 255, ${p.tint / 200})` : `rgba(0, 255, 0, ${Math.abs(p.tint) / 200})`;
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- }
- ctx.globalCompositeOperation = 'source-over';
- }
-
- if (this.shouldApplyPixelEffects()) {
- this.applyPixelEffectsRegion(ctx, 0, 0, canvas.width, canvas.height);
- }
-
- return new Promise((resolve, reject) => {
- canvas.toBlob(
- (blob) => {
- if (blob) {
- resolve(blob);
- } else {
- reject(new Error("Unable to export edited image."));
- }
- },
- "image/png",
- 0.95
- );
- });
- }
-
- // --- Save ---
- async save() {
- try {
- const blob = await this.renderEditedBlob();
- if (this.saveCallback) {
- await this.saveCallback(blob);
- }
- this.close();
- } catch (err) {
- console.error("[SDVN.ImageEditor] Failed to save image", err);
- }
- }
-
- async download() {
- try {
- const blob = await this.renderEditedBlob();
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = this.buildDownloadFilename();
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- setTimeout(() => URL.revokeObjectURL(url), 0);
- } catch (err) {
- console.error("[SDVN.ImageEditor] Failed to download image", err);
- }
- }
-
- buildDownloadFilename() {
- const fallback = "sdvn_image.png";
- if (!this.imageSrc) return fallback;
- try {
- const url = new URL(this.imageSrc, window.location.origin);
- const paramName = url.searchParams.get("filename");
- const pathName = url.pathname.split("/").pop();
- const base = (paramName || pathName || "sdvn_image").replace(/\.[^.]+$/, "");
- return `${base || "sdvn_image"}_edited.png`;
- } catch {
- const sanitized = this.imageSrc.split("/").pop()?.split("?")[0] ?? "sdvn_image";
- const base = sanitized.replace(/\.[^.]+$/, "");
- return `${base || "sdvn_image"}_edited.png`;
- }
- }
-
- close() {
- this.curveEditor?.destroy?.();
- document.body.removeChild(this.overlay);
- }
-}
diff --git a/Image_editor_example/image_editor_modules/extension.js b/Image_editor_example/image_editor_modules/extension.js
deleted file mode 100644
index a29e79e..0000000
--- a/Image_editor_example/image_editor_modules/extension.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import { ImageEditor } from "./editor.js";
-import { IMAGE_EDITOR_SUBFOLDER } from "./constants.js";
-import {
- parseImageWidgetValue,
- extractFilenameFromSrc,
- buildEditorFilename,
- buildImageReference,
- updateWidgetWithRef,
- createImageURLFromRef,
- setImageSource,
- refreshComboLists,
-} from "./reference.js";
-
-export function registerImageEditorExtension(app, api) {
- app.registerExtension({
- name: "SDVN.ImageEditor",
- async beforeRegisterNodeDef(nodeType) {
- const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
- nodeType.prototype.getExtraMenuOptions = function (_, options) {
- if (this.imgs && this.imgs.length > 0) {
- options.push({
- content: "🎨 Image Editor",
- callback: () => {
- const img = this.imgs[this.imgs.length - 1];
- let src = null;
- if (img && img.src) src = img.src;
- else if (img && img.image) src = img.image.src;
-
- if (src) {
- new ImageEditor(src, async (blob) => {
- const formData = new FormData();
- const inferredName = extractFilenameFromSrc(src);
- const editorName = buildEditorFilename(inferredName);
- formData.append("image", blob, editorName);
- formData.append("overwrite", "false");
- formData.append("type", "input");
- formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER);
-
- try {
- const resp = await api.fetchApi("/upload/image", {
- method: "POST",
- body: formData,
- });
- const data = await resp.json();
- const ref = buildImageReference(data, {
- type: "input",
- subfolder: IMAGE_EDITOR_SUBFOLDER,
- filename: editorName,
- });
- const imageWidget = this.widgets?.find?.(
- (w) => w.name === "image" || w.type === "image"
- );
- if (imageWidget) {
- updateWidgetWithRef(this, imageWidget, ref);
- }
- const newSrc = createImageURLFromRef(api, ref);
- if (newSrc) {
- setImageSource(img, newSrc);
- app.graph.setDirtyCanvas(true);
- }
- await refreshComboLists(app);
- console.info("[SDVN.ImageEditor] Image saved to input folder:", data?.name || editorName);
- } catch (e) {
- console.error("[SDVN.ImageEditor] Upload failed", e);
- }
- });
- }
- },
- });
- } else if (this.widgets) {
- const imageWidget = this.widgets.find((w) => w.name === "image" || w.type === "image");
- if (imageWidget && imageWidget.value) {
- options.push({
- content: "🎨 Image Editor",
- callback: () => {
- const parsed = parseImageWidgetValue(imageWidget.value);
- if (!parsed.filename) {
- console.warn("[SDVN.ImageEditor] Image not available for editing.");
- return;
- }
- const src = api.apiURL(
- `/view?filename=${encodeURIComponent(parsed.filename)}&type=${parsed.type}&subfolder=${encodeURIComponent(
- parsed.subfolder
- )}`
- );
-
- new ImageEditor(src, async (blob) => {
- const formData = new FormData();
- const newName = buildEditorFilename(parsed.filename);
- formData.append("image", blob, newName);
- formData.append("overwrite", "false");
- formData.append("type", "input");
- formData.append("subfolder", IMAGE_EDITOR_SUBFOLDER);
-
- try {
- const resp = await api.fetchApi("/upload/image", {
- method: "POST",
- body: formData,
- });
- const data = await resp.json();
- const ref = buildImageReference(data, {
- type: "input",
- subfolder: IMAGE_EDITOR_SUBFOLDER,
- filename: newName,
- });
-
- if (imageWidget) {
- updateWidgetWithRef(this, imageWidget, ref);
- }
-
- const newSrc = createImageURLFromRef(api, ref);
-
- if (this.imgs && this.imgs.length > 0) {
- this.imgs.forEach((img) => setImageSource(img, newSrc));
- }
-
- this.setDirtyCanvas?.(true, true);
- app.graph.setDirtyCanvas(true, true);
- await refreshComboLists(app);
- } catch (e) {
- console.error("[SDVN.ImageEditor] Upload failed", e);
- }
- });
- },
- });
- }
- }
- return getExtraMenuOptions?.apply(this, arguments);
- };
- },
- });
-}
diff --git a/Image_editor_example/image_editor_modules/reference.js b/Image_editor_example/image_editor_modules/reference.js
deleted file mode 100644
index 1d93aad..0000000
--- a/Image_editor_example/image_editor_modules/reference.js
+++ /dev/null
@@ -1,149 +0,0 @@
-export function buildImageReference(data, fallback = {}) {
- const ref = {
- filename: data?.name || data?.filename || fallback.filename,
- subfolder: data?.subfolder ?? fallback.subfolder ?? "",
- type: data?.type || fallback.type || "input",
- };
- if (!ref.filename) {
- return null;
- }
- return ref;
-}
-
-export function buildAnnotatedLabel(ref) {
- if (!ref?.filename) return "";
- const path = ref.subfolder ? `${ref.subfolder}/${ref.filename}` : ref.filename;
- return `${path} [${ref.type || "input"}]`;
-}
-
-export function parseImageWidgetValue(value) {
- const defaults = { filename: null, subfolder: "", type: "input" };
- if (!value) return defaults;
- if (typeof value === "object") {
- return {
- filename: value.filename || null,
- subfolder: value.subfolder || "",
- type: value.type || "input",
- };
- }
-
- const raw = value.toString().trim();
- let type = "input";
- let path = raw;
- const match = raw.match(/\[([^\]]+)\]\s*$/);
- if (match) {
- type = match[1].trim() || "input";
- path = raw.slice(0, match.index).trim();
- }
- path = path.replace(/^[\\/]+/, "");
- const parts = path.split(/[\\/]/).filter(Boolean);
- const filename = parts.pop() || null;
- const subfolder = parts.join("/") || "";
- return { filename, subfolder, type };
-}
-
-export function sanitizeFilenamePart(part) {
- return (part || "")
- .replace(/[\\/]/g, "_")
- .replace(/[<>:"|?*\x00-\x1F]/g, "_")
- .replace(/\s+/g, "_");
-}
-
-export function buildEditorFilename(sourceName) {
- let name = sourceName ? sourceName.toString() : "";
- name = name.split(/[\\/]/).pop() || "";
- name = name.replace(/\.[^.]+$/, "");
- name = sanitizeFilenamePart(name);
- if (!name) name = `image_${Date.now()}`;
- return `${name}.png`;
-}
-
-export function extractFilenameFromSrc(src) {
- if (!src) return null;
- try {
- const url = new URL(src, window.location.origin);
- return url.searchParams.get("filename");
- } catch {
- return null;
- }
-}
-
-export function formatWidgetValueFromRef(ref, currentValue) {
- if (currentValue && typeof currentValue === "object") {
- return {
- ...currentValue,
- filename: ref.filename,
- subfolder: ref.subfolder,
- type: ref.type,
- };
- }
- return buildAnnotatedLabel(ref);
-}
-
-export function updateWidgetWithRef(node, widget, ref) {
- if (!node || !widget || !ref) return;
- const annotatedLabel = buildAnnotatedLabel(ref);
- const storedValue = formatWidgetValueFromRef(ref, widget.value);
- widget.value = storedValue;
- widget.callback?.(storedValue);
- if (widget.inputEl) {
- widget.inputEl.value = annotatedLabel;
- }
-
- if (Array.isArray(node.widgets_values)) {
- const idx = node.widgets?.indexOf?.(widget) ?? -1;
- if (idx >= 0) {
- node.widgets_values[idx] = annotatedLabel;
- }
- }
-
- if (Array.isArray(node.inputs)) {
- node.inputs.forEach(input => {
- if (!input?.widget) return;
- if (input.widget === widget || (widget.name && input.widget.name === widget.name)) {
- input.widget.value = annotatedLabel;
- if (input.widget.inputEl) {
- input.widget.inputEl.value = annotatedLabel;
- }
- }
- });
- }
-
- if (typeof annotatedLabel === "string" && widget.options?.values) {
- const values = widget.options.values;
- if (Array.isArray(values) && !values.includes(annotatedLabel)) {
- values.push(annotatedLabel);
- }
- }
-}
-
-export function createImageURLFromRef(api, ref) {
- if (!ref?.filename) return null;
- const params = new URLSearchParams();
- params.set("filename", ref.filename);
- params.set("type", ref.type || "input");
- params.set("subfolder", ref.subfolder || "");
- params.set("t", Date.now().toString());
- return api.apiURL(`/view?${params.toString()}`);
-}
-
-export function setImageSource(target, newSrc) {
- if (!target || !newSrc) return;
- if (target instanceof Image) {
- target.src = newSrc;
- } else if (target.image instanceof Image) {
- target.image.src = newSrc;
- } else if (target.img instanceof Image) {
- target.img.src = newSrc;
- }
-}
-
-export async function refreshComboLists(app) {
- if (typeof app.refreshComboInNodes === "function") {
- try {
- await app.refreshComboInNodes();
- } catch (err) {
- console.warn("SDVN.ImageEditor: refreshComboInNodes failed", err);
- }
- }
-}
diff --git a/Image_editor_example/image_editor_modules/styles.js b/Image_editor_example/image_editor_modules/styles.js
deleted file mode 100644
index e273990..0000000
--- a/Image_editor_example/image_editor_modules/styles.js
+++ /dev/null
@@ -1,435 +0,0 @@
-const STYLE_ID = "sdvn-image-editor-style";
-
-const IMAGE_EDITOR_CSS = `
- :root {
- --apix-bg: #0f0f0f;
- --apix-panel: #1a1a1a;
- --apix-border: #2a2a2a;
- --apix-text: #e0e0e0;
- --apix-text-dim: #888;
- --apix-accent: #f5c518; /* Yellow accent from apix */
- --apix-accent-hover: #ffd54f;
- --apix-danger: #ff4444;
- }
- .apix-overlay {
- position: fixed;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100vh;
- background: var(--apix-bg);
- z-index: 10000;
- display: flex;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
- color: var(--apix-text);
- overflow: hidden;
- user-select: none;
- }
-
- /* Left Sidebar (Tools) */
- .apix-sidebar-left {
- width: 60px;
- background: var(--apix-panel);
- border-right: 1px solid var(--apix-border);
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 20px 0;
- gap: 15px;
- z-index: 10;
- }
-
- /* Main Canvas Area */
- .apix-main-area {
- flex: 1;
- display: flex;
- flex-direction: column;
- position: relative;
- background: #000;
- overflow: hidden;
- }
- .apix-header {
- height: 50px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 20px;
- background: var(--apix-panel);
- border-bottom: 1px solid var(--apix-border);
- }
- .apix-header-title {
- font-weight: 700;
- color: var(--apix-accent);
- font-size: 18px;
- display: flex;
- align-items: center;
- gap: 10px;
- }
-
- .apix-canvas-container {
- flex: 1;
- position: relative;
- overflow: hidden;
- display: flex;
- justify-content: center;
- align-items: center;
- cursor: grab;
- }
- .apix-canvas-container:active {
- cursor: grabbing;
- }
-
- /* Bottom Bar (Zoom) */
- .apix-bottom-bar {
- height: 40px;
- background: var(--apix-panel);
- border-top: 1px solid var(--apix-border);
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 15px;
- font-size: 12px;
- }
-
-/* Right Sidebar (Adjustments) */
-.apix-sidebar-right {
- width: 320px;
- background: var(--apix-panel);
- border-left: 1px solid var(--apix-border);
- display: flex;
- flex-direction: column;
- z-index: 10;
- height: 100vh;
- max-height: 100vh;
- overflow: hidden;
-}
-.apix-sidebar-scroll {
- flex: 1;
- overflow-y: auto;
- padding-bottom: 20px;
- scrollbar-width: thin;
- scrollbar-color: var(--apix-accent) transparent;
-}
-.apix-sidebar-scroll::-webkit-scrollbar {
- width: 6px;
-}
-.apix-sidebar-scroll::-webkit-scrollbar-thumb {
- background: var(--apix-accent);
- border-radius: 3px;
-}
-.apix-sidebar-scroll::-webkit-scrollbar-track {
- background: transparent;
-}
-
- /* UI Components */
- .apix-tool-btn {
- width: 40px;
- height: 40px;
- border-radius: 8px;
- border: 1px solid transparent;
- background: transparent;
- color: var(--apix-text-dim);
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s;
- }
- .apix-tool-btn:hover {
- color: var(--apix-text);
- background: rgba(255,255,255,0.05);
- }
-.apix-tool-btn.active {
- color: #000;
- background: var(--apix-accent);
-}
-.apix-tool-btn.icon-only svg {
- width: 18px;
- height: 18px;
-}
-.apix-sidebar-divider {
- width: 24px;
- height: 1px;
- background: var(--apix-border);
- margin: 12px 0;
-}
-
- .apix-panel-section {
- border-bottom: 1px solid var(--apix-border);
- }
-.apix-panel-header {
- padding: 15px;
- font-weight: 600;
- font-size: 13px;
- cursor: pointer;
- display: flex;
- justify-content: space-between;
- align-items: center;
- background: rgba(255,255,255,0.02);
- user-select: none;
-}
-.apix-panel-header span:first-child {
- color: #8d8d8d;
- font-weight: 700;
- letter-spacing: 0.3px;
-}
-.apix-panel-header:hover {
- background: rgba(255,255,255,0.05);
-}
- .apix-panel-content {
- padding: 15px;
- display: flex;
- flex-direction: column;
- gap: 15px;
- }
- .apix-panel-content.hidden {
- display: none;
- }
-
- .apix-control-row {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
-.apix-control-label {
- display: flex;
- justify-content: space-between;
- font-size: 12px;
- color: var(--apix-text-dim);
- letter-spacing: 0.2px;
- font-weight: 600;
-}
-.apix-slider-meta {
- display: flex;
- align-items: center;
- justify-content: flex-end;
-}
-.apix-slider-meta span {
- min-width: 36px;
- text-align: right;
- font-variant-numeric: tabular-nums;
-}
-.apix-slider-wrapper {
- position: relative;
- width: 100%;
- padding-right: 26px;
-}
-.apix-slider-reset {
- border: none;
- background: transparent;
- color: var(--apix-text-dim);
- cursor: pointer;
- width: 22px;
- height: 22px;
- position: absolute;
- right: 0;
- top: 56%;
- transform: translateY(-50%);
- opacity: 0.4;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: opacity 0.2s, color 0.2s;
-}
-.apix-slider-reset:hover {
- opacity: 1;
- color: var(--apix-accent);
-}
-.apix-slider-reset svg {
- width: 12px;
- height: 12px;
- pointer-events: none;
-}
-
-.apix-curve-panel {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-.apix-curve-toolbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: 11px;
- color: var(--apix-text-dim);
- gap: 8px;
-}
-.apix-curve-channel-buttons {
- display: flex;
- gap: 6px;
-}
-.apix-curve-channel-btn {
- border: 1px solid var(--apix-border);
- background: transparent;
- color: var(--apix-text-dim);
- font-size: 10px;
- padding: 2px 8px;
- border-radius: 999px;
- cursor: pointer;
- transition: all 0.2s;
-}
-.apix-curve-channel-btn.active {
- background: var(--apix-accent);
- color: #000;
- border-color: var(--apix-accent);
-}
-.apix-curve-reset {
- border: none;
- background: transparent;
- color: var(--apix-accent);
- font-size: 11px;
- cursor: pointer;
- padding: 0 4px;
-}
-.apix-curve-stage {
- width: 100%;
- height: 240px;
- border: 1px solid var(--apix-border);
- border-radius: 8px;
- background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.25) 100%);
- position: relative;
- overflow: hidden;
-}
-.apix-curve-stage canvas {
- width: 100%;
- height: 100%;
- display: block;
-}
-
- .apix-slider {
- -webkit-appearance: none;
- width: 100%;
- height: 4px;
- background: #333;
- border-radius: 2px;
- outline: none;
- }
- .apix-slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 12px;
- height: 12px;
- border-radius: 50%;
- background: var(--apix-accent);
- cursor: pointer;
- border: 2px solid #1a1a1a;
- transition: transform 0.1s;
- }
- .apix-slider::-webkit-slider-thumb:hover {
- transform: scale(1.2);
- }
-
- .apix-btn {
- padding: 8px 16px;
- border-radius: 6px;
- border: none;
- font-weight: 600;
- cursor: pointer;
- font-size: 12px;
- transition: all 0.2s;
- }
-.apix-btn-primary {
- background: var(--apix-accent);
- color: #000;
-}
-.apix-btn-primary:hover {
- background: var(--apix-accent-hover);
-}
-.apix-btn-secondary {
- background: #333;
- color: #fff;
-}
-.apix-btn-secondary:hover {
- background: #444;
-}
-.apix-btn-toggle.active {
- background: var(--apix-accent);
- color: #000;
-}
-.apix-hsl-swatches {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
-}
-.apix-hsl-chip {
- width: 26px;
- height: 26px;
- border-radius: 50%;
- border: 2px solid transparent;
- background: var(--chip-color, #fff);
- cursor: pointer;
- transition: transform 0.2s, border 0.2s;
-}
-.apix-hsl-chip.active {
- border-color: var(--apix-accent);
- transform: scale(1.05);
-}
-.apix-hsl-slider .apix-slider-meta span {
- font-size: 11px;
- color: var(--apix-text-dim);
-}
-.apix-hsl-actions {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- align-items: center;
- font-size: 11px;
- color: var(--apix-text-dim);
-}
-.apix-hsl-reset {
- border: none;
- background: transparent;
- color: var(--apix-accent);
- cursor: pointer;
- font-size: 11px;
-}
-
- .apix-sidebar-right {
- position: relative;
- }
- .apix-footer {
- padding: 20px;
- border-top: 1px solid var(--apix-border);
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- background: var(--apix-panel);
- }
-
- /* Crop Overlay */
- .apix-crop-overlay {
- position: absolute;
- border: 1px solid rgba(255, 255, 255, 0.5);
- box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.7);
- pointer-events: none;
- display: none;
- }
- .apix-crop-handle {
- position: absolute;
- width: 12px;
- height: 12px;
- background: var(--apix-accent);
- border: 1px solid #000;
- pointer-events: auto;
- z-index: 100;
- }
- /* Handle positions */
- .handle-tl { top: -6px; left: -6px; cursor: nw-resize; }
- .handle-tr { top: -6px; right: -6px; cursor: ne-resize; }
- .handle-bl { bottom: -6px; left: -6px; cursor: sw-resize; }
- .handle-br { bottom: -6px; right: -6px; cursor: se-resize; }
- /* Edges */
- .handle-t { top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; }
- .handle-b { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
- .handle-l { left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; }
- .handle-r { right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; }
-`;
-
-export function injectImageEditorStyles() {
- if (document.getElementById(STYLE_ID)) {
- return;
- }
- const style = document.createElement("style");
- style.id = STYLE_ID;
- style.textContent = IMAGE_EDITOR_CSS;
- document.head.appendChild(style);
-}
diff --git a/__pycache__/app.cpython-311.pyc b/__pycache__/app.cpython-311.pyc
deleted file mode 100644
index 075c115..0000000
Binary files a/__pycache__/app.cpython-311.pyc and /dev/null differ
diff --git a/__pycache__/app.cpython-314.pyc b/__pycache__/app.cpython-314.pyc
deleted file mode 100644
index 20736e5..0000000
Binary files a/__pycache__/app.cpython-314.pyc and /dev/null differ
diff --git a/__pycache__/whisk_client.cpython-314.pyc b/__pycache__/whisk_client.cpython-314.pyc
deleted file mode 100644
index ff80237..0000000
Binary files a/__pycache__/whisk_client.cpython-314.pyc and /dev/null differ
diff --git a/app.py b/app.py
index e5a6d35..0e936ae 100644
--- a/app.py
+++ b/app.py
@@ -586,10 +586,13 @@ def generate_image():
reference_image_path = ref_url
# Call the client
+ image_count = int(data.get('image_count', 4)) if not multipart else int(form.get('image_count', 4))
+
try:
whisk_result = whisk_client.generate_image_whisk(
prompt=api_prompt,
cookie_str=cookie_str,
+ image_count=image_count,
aspect_ratio=aspect_ratio,
resolution=resolution,
reference_image_path=reference_image_path
@@ -598,26 +601,28 @@ def generate_image():
# Re-raise to be caught by the outer block
raise e
- # Process result - whisk_client returns raw bytes
- image_bytes = None
- if isinstance(whisk_result, bytes):
- image_bytes = whisk_result
+ # Process result - whisk_client returns List[bytes] or bytes (in case of fallback/legacy)
+ image_bytes_list = []
+ if isinstance(whisk_result, list):
+ image_bytes_list = whisk_result
+ elif isinstance(whisk_result, bytes):
+ image_bytes_list = [whisk_result]
elif isinstance(whisk_result, dict):
# Fallback if I ever change the client to return dict
if 'image_data' in whisk_result:
- image_bytes = whisk_result['image_data']
+ image_bytes_list = [whisk_result['image_data']]
elif 'image_url' in whisk_result:
import requests
img_resp = requests.get(whisk_result['image_url'])
- image_bytes = img_resp.content
+ image_bytes_list = [img_resp.content]
- if not image_bytes:
+ if not image_bytes_list:
raise ValueError("No image data returned from Whisk.")
- # Save and process image (Reuse existing logic)
- image = Image.open(BytesIO(image_bytes))
- png_info = PngImagePlugin.PngInfo()
-
+ # Process all images
+ saved_urls = []
+ saved_b64s = []
+
date_str = datetime.now().strftime("%Y%m%d")
search_pattern = os.path.join(GENERATED_DIR, f"whisk_{date_str}_*.png")
existing_files = glob.glob(search_pattern)
@@ -626,41 +631,58 @@ def generate_image():
try:
basename = os.path.basename(f)
name_without_ext = os.path.splitext(basename)[0]
- id_part = name_without_ext.split('_')[-1]
- id_num = int(id_part)
- if id_num > max_id:
- max_id = id_num
- except ValueError:
+ parts = name_without_ext.split('_')
+ # Check for batch_ID part
+ if len(parts) >= 3:
+ id_part = parts[2]
+ id_num = int(id_part)
+ if id_num > max_id:
+ max_id = id_num
+ elif len(parts) == 2:
+ pass
+ except (ValueError, IndexError):
continue
-
- next_id = max_id + 1
- filename = f"whisk_{date_str}_{next_id}.png"
- filepath = os.path.join(GENERATED_DIR, filename)
- rel_path = os.path.join('generated', filename)
- image_url = url_for('static', filename=rel_path)
+
+ next_batch_id = max_id + 1
- metadata = {
- 'prompt': prompt,
- 'note': note,
- 'processed_prompt': api_prompt,
- 'aspect_ratio': aspect_ratio or 'Auto',
- 'resolution': resolution,
- 'reference_images': final_reference_paths,
- 'model': 'whisk'
- }
- png_info.add_text('sdvn_meta', json.dumps(metadata))
+ for idx, img_bytes in enumerate(image_bytes_list):
+ image = Image.open(BytesIO(img_bytes))
+ png_info = PngImagePlugin.PngInfo()
- buffer = BytesIO()
- image.save(buffer, format='PNG', pnginfo=png_info)
- final_bytes = buffer.getvalue()
+ filename = f"whisk_{date_str}_{next_batch_id}_{idx}.png"
+ filepath = os.path.join(GENERATED_DIR, filename)
+ rel_path = os.path.join('generated', filename)
+ image_url = url_for('static', filename=rel_path)
- with open(filepath, 'wb') as f:
- f.write(final_bytes)
+ metadata = {
+ 'prompt': prompt,
+ 'note': note,
+ 'processed_prompt': api_prompt,
+ 'aspect_ratio': aspect_ratio or 'Auto',
+ 'resolution': resolution,
+ 'reference_images': final_reference_paths,
+ 'model': 'whisk',
+ 'batch_id': next_batch_id,
+ 'batch_index': idx
+ }
+ png_info.add_text('sdvn_meta', json.dumps(metadata))
+
+ buffer = BytesIO()
+ image.save(buffer, format='PNG', pnginfo=png_info)
+ final_bytes = buffer.getvalue()
+
+ with open(filepath, 'wb') as f:
+ f.write(final_bytes)
+
+ b64_str = base64.b64encode(final_bytes).decode('utf-8')
+ saved_urls.append(image_url)
+ saved_b64s.append(b64_str)
- image_data = base64.b64encode(final_bytes).decode('utf-8')
return jsonify({
- 'image': image_url,
- 'image_data': image_data,
+ 'image': saved_urls[0], # Legacy support
+ 'images': saved_urls, # New support
+ 'image_data': saved_b64s[0], # Legacy
+ 'image_datas': saved_b64s, # New
'metadata': metadata,
})
@@ -797,661 +819,135 @@ def get_prompts():
# Read prompts.json file
prompts_path = get_config_path('prompts.json')
if os.path.exists(prompts_path):
- with open(prompts_path, 'r', encoding='utf-8') as f:
- try:
- builtin_prompts = json.load(f)
- if isinstance(builtin_prompts, list):
- for idx, prompt in enumerate(builtin_prompts):
- prompt['builtinTemplateIndex'] = idx
- prompt['tags'] = parse_tags_field(prompt.get('tags'))
- all_prompts.extend(builtin_prompts)
- except json.JSONDecodeError:
- pass
-
- # Read user_prompts.json file and mark as user templates
+ with open(prompts_path, 'r', encoding='utf-8') as f:
+ core_data = json.load(f)
+ if isinstance(core_data, list):
+ all_prompts.extend(core_data)
+
+ # Read user_prompts.json file
user_prompts_path = get_config_path('user_prompts.json')
if os.path.exists(user_prompts_path):
- try:
- with open(user_prompts_path, 'r', encoding='utf-8') as f:
- user_prompts = json.load(f)
- if isinstance(user_prompts, list):
- # Mark each user template and add index for editing
- for idx, template in enumerate(user_prompts):
- template['isUserTemplate'] = True
- template['userTemplateIndex'] = idx
- template['tags'] = parse_tags_field(template.get('tags'))
- all_prompts.extend(user_prompts)
- except json.JSONDecodeError:
- pass # Ignore if empty or invalid
+ with open(user_prompts_path, 'r', encoding='utf-8') as f:
+ user_data = json.load(f)
+ if isinstance(user_data, list):
+ all_prompts.extend(user_data)
- # Filter by category if specified
+ # Filter by category if provided
if category:
- all_prompts = [p for p in all_prompts if p.get('category') == category]
+ filtered_prompts = [p for p in all_prompts if p.get('category') == category]
+ return jsonify(filtered_prompts)
- favorites = load_template_favorites()
- response = jsonify({'prompts': all_prompts, 'favorites': favorites})
- response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
- return response
+ return jsonify(all_prompts)
except Exception as e:
- return jsonify({'error': str(e)}), 500
+ print(f"Error reading prompts: {e}")
+ return jsonify([])
-
-@app.route('/template_favorite', methods=['POST'])
-def template_favorite():
- data = request.get_json() or {}
- key = data.get('key')
- favorite = data.get('favorite')
-
- if not key or not isinstance(favorite, bool):
- return jsonify({'error': 'Invalid favorite payload'}), 400
-
- favorites = load_template_favorites()
-
- if favorite:
- if key not in favorites:
- favorites.append(key)
- else:
- favorites = [item for item in favorites if item != key]
-
- save_template_favorites(favorites)
- return jsonify({'favorites': favorites})
-
-@app.route('/gallery_favorites', methods=['GET'])
-def get_gallery_favorites():
- favorites = load_gallery_favorites()
- return jsonify({'favorites': favorites})
-
-@app.route('/toggle_gallery_favorite', methods=['POST'])
-def toggle_gallery_favorite():
- data = request.get_json() or {}
- filename = data.get('filename')
- source = data.get('source')
- rel_path = data.get('path') or data.get('relative_path')
-
- resolved_source, _, storage_key = resolve_gallery_target(source, filename, rel_path)
- if not storage_key:
- return jsonify({'error': 'Filename is required'}), 400
-
- favorites = load_gallery_favorites()
- legacy_key = os.path.basename(storage_key)
-
- if storage_key in favorites or legacy_key in favorites:
- favorites = [item for item in favorites if item not in (storage_key, legacy_key)]
- is_favorite = False
- else:
- favorites.append(storage_key)
- is_favorite = True
-
- save_gallery_favorites(favorites)
- return jsonify({'favorites': favorites, 'is_favorite': is_favorite, 'source': resolved_source})
-
-@app.route('/save_template', methods=['POST'])
-def save_template():
+@app.route('/save_prompt', methods=['POST'])
+def save_prompt():
+ data = request.get_json()
+ new_prompt = {
+ 'act': data.get('act'),
+ 'prompt': data.get('prompt'),
+ 'category': 'User Saved',
+ 'desc': data.get('desc', '')
+ }
+
+ user_prompts_path = get_config_path('user_prompts.json')
try:
- import requests
- from urllib.parse import urlparse
-
- # Handle multipart form data
- title = request.form.get('title')
- prompt = request.form.get('prompt')
- mode = request.form.get('mode', 'generate')
- note = request.form.get('note', '')
- category = request.form.get('category', 'User')
- tags_field = request.form.get('tags')
- tags = parse_tags_field(tags_field)
-
- if not title or not prompt:
- return jsonify({'error': 'Title and prompt are required'}), 400
-
- # Handle preview image
- preview_path = None
- preview_dir = os.path.join(app.static_folder, 'preview')
- os.makedirs(preview_dir, exist_ok=True)
-
- # Check if file was uploaded
- if 'preview' in request.files:
- file = request.files['preview']
- if file.filename:
- ext = os.path.splitext(file.filename)[1] or '.png'
- file.stream.seek(0)
- file_bytes = file.read()
- preview_filename = save_preview_image(
- preview_dir=preview_dir,
- extension=ext,
- source_bytes=file_bytes
- )
-
- if preview_filename:
- preview_path = url_for('static', filename=f'preview/{preview_filename}')
-
- # If no file uploaded, check if URL/path provided
- if not preview_path:
- preview_url = request.form.get('preview_path')
- if preview_url:
- try:
- # Check if it's a URL or local path
- if preview_url.startswith('http://') or preview_url.startswith('https://'):
- # Download from URL
- response = requests.get(preview_url, timeout=10)
- response.raise_for_status()
-
- # Determine extension from content-type or URL
- content_type = response.headers.get('content-type', '')
- if 'image/png' in content_type:
- ext = '.png'
- elif 'image/jpeg' in content_type or 'image/jpg' in content_type:
- ext = '.jpg'
- elif 'image/webp' in content_type:
- ext = '.webp'
- else:
- # Try to get from URL
- parsed = urlparse(preview_url)
- ext = os.path.splitext(parsed.path)[1] or '.png'
-
- preview_filename = save_preview_image(
- preview_dir=preview_dir,
- extension=ext,
- source_bytes=response.content
- )
-
- if preview_filename:
- preview_path = url_for('static', filename=f'preview/{preview_filename}')
- else:
- preview_path = preview_url
-
- elif preview_url.startswith('/static/'):
- # Local path - copy to preview folder
- rel_path = preview_url.split('/static/')[1]
- source_path = os.path.join(app.static_folder, rel_path)
-
- if os.path.exists(source_path):
- ext = os.path.splitext(source_path)[1] or '.png'
- preview_filename = save_preview_image(
- preview_dir=preview_dir,
- extension=ext,
- source_path=source_path
- )
-
- if preview_filename:
- preview_path = url_for('static', filename=f'preview/{preview_filename}')
- else:
- preview_path = preview_url
- else:
- # File doesn't exist, use original path
- preview_path = preview_url
- else:
- # Use as-is if it's already a valid path
- preview_path = preview_url
-
- except Exception as e:
- print(f"Error processing preview image URL: {e}")
- # Use the original URL if processing fails
- preview_path = preview_url
-
- new_template = {
- 'title': title,
- 'prompt': prompt,
- 'note': note,
- 'mode': mode,
- 'category': category,
- 'preview': preview_path,
- 'tags': tags
- }
-
- # Save to user_prompts.json
- user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
- user_prompts = []
-
+ existing_prompts = []
if os.path.exists(user_prompts_path):
- try:
- with open(user_prompts_path, 'r', encoding='utf-8') as f:
- content = f.read()
- if content.strip():
- user_prompts = json.loads(content)
- except json.JSONDecodeError:
- pass
-
- user_prompts.append(new_template)
+ with open(user_prompts_path, 'r', encoding='utf-8') as f:
+ existing_prompts = json.load(f)
+
+ existing_prompts.append(new_prompt)
with open(user_prompts_path, 'w', encoding='utf-8') as f:
- json.dump(user_prompts, f, indent=4, ensure_ascii=False)
-
- return jsonify({'success': True, 'template': new_template})
-
- except Exception as e:
- print(f"Error saving template: {e}")
- return jsonify({'error': str(e)}), 500
-
-@app.route('/update_template', methods=['POST'])
-def update_template():
- try:
- import requests
- from urllib.parse import urlparse
-
- template_index = request.form.get('template_index')
- builtin_index_raw = request.form.get('builtin_index')
- builtin_index = None
-
- try:
- if builtin_index_raw:
- builtin_index = int(builtin_index_raw)
- except ValueError:
- return jsonify({'error': 'Invalid builtin template index'}), 400
-
- if template_index is None and builtin_index is None:
- return jsonify({'error': 'Template index or builtin index is required'}), 400
-
- if template_index is not None:
- try:
- template_index = int(template_index)
- except ValueError:
- return jsonify({'error': 'Invalid template index'}), 400
-
- title = request.form.get('title')
- prompt = request.form.get('prompt')
- mode = request.form.get('mode', 'generate')
- note = request.form.get('note', '')
- category = request.form.get('category', 'User')
- tags_field = request.form.get('tags')
- tags = parse_tags_field(tags_field)
-
- if not title or not prompt:
- return jsonify({'error': 'Title and prompt are required'}), 400
-
- preview_path = None
- preview_dir = os.path.join(app.static_folder, 'preview')
- os.makedirs(preview_dir, exist_ok=True)
-
- if 'preview' in request.files:
- file = request.files['preview']
- if file.filename:
- ext = os.path.splitext(file.filename)[1] or '.png'
- file.stream.seek(0)
- file_bytes = file.read()
- preview_filename = save_preview_image(
- preview_dir=preview_dir,
- extension=ext,
- source_bytes=file_bytes
- )
-
- if preview_filename:
- preview_path = url_for('static', filename=f'preview/{preview_filename}')
-
- if not preview_path:
- preview_url = request.form.get('preview_path')
- if preview_url:
- try:
- if preview_url.startswith('http://') or preview_url.startswith('https://'):
- response = requests.get(preview_url, timeout=10)
- response.raise_for_status()
-
- content_type = response.headers.get('content-type', '')
- if 'image/png' in content_type:
- ext = '.png'
- elif 'image/jpeg' in content_type or 'image/jpg' in content_type:
- ext = '.jpg'
- elif 'image/webp' in content_type:
- ext = '.webp'
- else:
- parsed = urlparse(preview_url)
- ext = os.path.splitext(parsed.path)[1] or '.png'
-
- preview_filename = save_preview_image(
- preview_dir=preview_dir,
- extension=ext,
- source_bytes=response.content
- )
-
- if preview_filename:
- preview_path = url_for('static', filename=f'preview/{preview_filename}')
- else:
- preview_path = preview_url
-
- elif preview_url.startswith('/static/'):
- rel_path = preview_url.split('/static/')[1]
- source_path = os.path.join(app.static_folder, rel_path)
-
- if os.path.exists(source_path):
- ext = os.path.splitext(source_path)[1] or '.png'
- preview_filename = save_preview_image(
- preview_dir=preview_dir,
- extension=ext,
- source_path=source_path
- )
-
- if preview_filename:
- preview_path = url_for('static', filename=f'preview/{preview_filename}')
- else:
- preview_path = preview_url
- else:
- preview_path = preview_url
- else:
- preview_path = preview_url
-
- except Exception as e:
- print(f"Error processing preview image URL: {e}")
- preview_path = preview_url
-
- if builtin_index is not None:
- prompts_path = os.path.join(os.path.dirname(__file__), 'prompts.json')
- if not os.path.exists(prompts_path):
- return jsonify({'error': 'Prompts file not found'}), 404
-
- try:
- with open(prompts_path, 'r', encoding='utf-8') as f:
- builtin_prompts = json.load(f)
- except json.JSONDecodeError:
- return jsonify({'error': 'Unable to read prompts.json'}), 500
-
- if not isinstance(builtin_prompts, list) or builtin_index < 0 or builtin_index >= len(builtin_prompts):
- return jsonify({'error': 'Invalid builtin template index'}), 400
-
- existing_template = builtin_prompts[builtin_index]
- old_preview = existing_template.get('preview', '')
-
- if preview_path and old_preview and '/preview/' in old_preview:
- try:
- old_filename = old_preview.split('/preview/')[-1]
- old_filepath = os.path.join(preview_dir, old_filename)
- if os.path.exists(old_filepath):
- os.remove(old_filepath)
- except Exception as e:
- print(f"Error deleting old preview image: {e}")
-
- existing_template['title'] = title
- existing_template['prompt'] = prompt
- existing_template['note'] = note
- existing_template['mode'] = mode
- existing_template['category'] = category
- if preview_path:
- existing_template['preview'] = preview_path
- existing_template['tags'] = tags
- builtin_prompts[builtin_index] = existing_template
-
- with open(prompts_path, 'w', encoding='utf-8') as f:
- json.dump(builtin_prompts, f, indent=4, ensure_ascii=False)
-
- existing_template['builtinTemplateIndex'] = builtin_index
- return jsonify({'success': True, 'template': existing_template})
-
- # Fallback to user template update
- user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
- user_prompts = []
-
- if os.path.exists(user_prompts_path):
- try:
- with open(user_prompts_path, 'r', encoding='utf-8') as f:
- content = f.read()
- if content.strip():
- user_prompts = json.loads(content)
- except json.JSONDecodeError:
- pass
-
- if template_index < 0 or template_index >= len(user_prompts):
- return jsonify({'error': 'Invalid template index'}), 400
-
- old_template = user_prompts[template_index]
- old_preview = old_template.get('preview', '')
- if preview_path and old_preview and '/preview/' in old_preview:
- try:
- old_filename = old_preview.split('/preview/')[-1]
- old_filepath = os.path.join(preview_dir, old_filename)
- if os.path.exists(old_filepath):
- os.remove(old_filepath)
- except Exception as e:
- print(f"Error deleting old preview image: {e}")
-
- user_prompts[template_index] = {
- 'title': title,
- 'prompt': prompt,
- 'note': note,
- 'mode': mode,
- 'category': category,
- 'preview': preview_path,
- 'tags': tags
- }
-
- with open(user_prompts_path, 'w', encoding='utf-8') as f:
- json.dump(user_prompts, f, indent=4, ensure_ascii=False)
-
- user_prompts[template_index]['isUserTemplate'] = True
- user_prompts[template_index]['userTemplateIndex'] = template_index
- return jsonify({'success': True, 'template': user_prompts[template_index]})
-
- except Exception as e:
- print(f"Error updating template: {e}")
- return jsonify({'error': str(e)}), 500
-
-@app.route('/delete_template', methods=['POST'])
-def delete_template():
- try:
- template_index = request.form.get('template_index')
- if template_index is None:
- return jsonify({'error': 'Template index is required'}), 400
-
- try:
- template_index = int(template_index)
- except ValueError:
- return jsonify({'error': 'Invalid template index'}), 400
-
- user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
- if not os.path.exists(user_prompts_path):
- return jsonify({'error': 'User prompts file not found'}), 404
-
- with open(user_prompts_path, 'r', encoding='utf-8') as f:
- user_prompts = json.load(f)
-
- if template_index < 0 or template_index >= len(user_prompts):
- return jsonify({'error': 'Template not found'}), 404
-
- template_to_delete = user_prompts[template_index]
-
- # Delete preview image if it exists and is local
- preview_path = template_to_delete.get('preview')
- if preview_path and '/static/preview/' in preview_path:
- # Extract filename
- try:
- filename = preview_path.split('/static/preview/')[1]
- preview_dir = os.path.join(app.static_folder, 'preview')
- filepath = os.path.join(preview_dir, filename)
- if os.path.exists(filepath):
- os.remove(filepath)
- except Exception as e:
- print(f"Error deleting preview image: {e}")
-
- # Remove from list
- del user_prompts[template_index]
-
- # Save back
- with open(user_prompts_path, 'w', encoding='utf-8') as f:
- json.dump(user_prompts, f, indent=4, ensure_ascii=False)
+ json.dump(existing_prompts, f, ensure_ascii=False, indent=4)
return jsonify({'success': True})
-
except Exception as e:
return jsonify({'error': str(e)}), 500
-@app.route('/refine_prompt', methods=['POST'])
-def refine_prompt():
+@app.route('/save_template_favorite', methods=['POST'])
+def save_template_fav():
data = request.get_json()
- current_prompt = data.get('current_prompt')
- instruction = data.get('instruction')
- api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY')
+ template_name = data.get('template')
+ if not template_name:
+ return jsonify({'error': 'Template name required'}), 400
- if not api_key:
- return jsonify({'error': 'API Key is required.'}), 401
+ favorites = load_template_favorites()
+ if template_name not in favorites:
+ favorites.insert(0, template_name)
+ save_template_favorites(favorites)
- if not instruction:
- return jsonify({'error': 'Instruction is required'}), 400
+ return jsonify({'success': True, 'favorites': favorites})
- try:
- client = genai.Client(api_key=api_key)
-
- system_instruction = "You are an expert prompt engineer for image generation AI. Rewrite the prompt to incorporate the user's instruction while maintaining the original intent and improving quality. Return ONLY the new prompt text, no explanations."
-
- prompt_content = f"Current prompt: {current_prompt}\nUser instruction: {instruction}\nNew prompt:"
-
- print(f"Refining prompt with instruction: {instruction}")
-
- response = client.models.generate_content(
- model="gemini-2.5-flash",
- contents=[prompt_content],
- config=types.GenerateContentConfig(
- system_instruction=system_instruction,
- temperature=0.7,
- )
- )
-
- if response.text:
- return jsonify({'refined_prompt': response.text.strip()})
- else:
- return jsonify({'error': 'No response from AI'}), 500
+@app.route('/remove_template_favorite', methods=['POST'])
+def remove_template_fav():
+ data = request.get_json()
+ template_name = data.get('template')
+ if not template_name:
+ return jsonify({'error': 'Template name required'}), 400
- except Exception as e:
- return jsonify({'error': str(e)}), 500
-
-#Tun sever
-
-@app.route('/download_image', methods=['POST'])
-def download_image():
- import requests
- from urllib.parse import urlparse
-
- data = request.get_json() or {}
- url = data.get('url')
+ favorites = load_template_favorites()
+ if template_name in favorites:
+ favorites.remove(template_name)
+ save_template_favorites(favorites)
- if not url:
- return jsonify({'error': 'URL is required'}), 400
+ return jsonify({'success': True, 'favorites': favorites})
+@app.route('/get_template_favorites')
+def get_template_favs():
+ return jsonify(load_template_favorites())
+
+@app.route('/save_gallery_favorite', methods=['POST'])
+def save_gallery_fav():
+ data = request.get_json()
+ image_url = data.get('url')
+ if not image_url:
+ return jsonify({'error': 'URL required'}), 400
+
+ favorites = load_gallery_favorites()
+ if image_url not in favorites:
+ favorites.insert(0, image_url)
+ save_gallery_favorites(favorites)
+
+ return jsonify({'success': True, 'favorites': favorites})
+
+@app.route('/remove_gallery_favorite', methods=['POST'])
+def remove_gallery_fav():
+ data = request.get_json()
+ image_url = data.get('url')
+ if not image_url:
+ return jsonify({'error': 'URL required'}), 400
+
+ favorites = load_gallery_favorites()
+ if image_url in favorites:
+ favorites.remove(image_url)
+ save_gallery_favorites(favorites)
+
+ return jsonify({'success': True, 'favorites': favorites})
+
+@app.route('/get_gallery_favorites')
+def get_gallery_favs():
+ return jsonify(load_gallery_favorites())
+
+def open_browser(url):
+ time.sleep(1.5)
+ print(f"Opening browser at {url}")
try:
- download_url = url
-
- # Check if it's a URL (http/https)
- if url.startswith('http://') or url.startswith('https://'):
- # Try to use gallery-dl to extract the image URL
- try:
- # -g: get URLs, -q: quiet
- cmd = ['gallery-dl', '-g', '-q', url]
- # Timeout to prevent hanging on slow sites
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
-
- if result.returncode == 0:
- urls = result.stdout.strip().split('\n')
- if urls and urls[0] and urls[0].startswith('http'):
- download_url = urls[0]
- except Exception as e:
- print(f"gallery-dl extraction failed (using direct URL): {e}")
- # Fallback to using the original URL directly
-
- # Download logic (for both direct URL and extracted URL)
- if download_url.startswith('http://') or download_url.startswith('https://'):
- response = requests.get(download_url, timeout=30)
- response.raise_for_status()
-
- content_type = response.headers.get('content-type', '')
- ext = '.png'
- if 'image/jpeg' in content_type: ext = '.jpg'
- elif 'image/webp' in content_type: ext = '.webp'
- elif 'image/gif' in content_type: ext = '.gif'
- else:
- parsed = urlparse(download_url)
- ext = os.path.splitext(parsed.path)[1] or '.png'
-
- filename = f"{uuid.uuid4()}{ext}"
- filepath = os.path.join(UPLOADS_DIR, filename)
-
- with open(filepath, 'wb') as f:
- f.write(response.content)
-
- rel_path = f"uploads/{filename}"
- final_url = url_for('static', filename=rel_path)
-
- return jsonify({'path': final_url, 'local_path': filepath})
-
- else:
- # Handle local file path
- # Remove quotes if present
- clean_path = url.strip('"\'')
-
- if os.path.exists(clean_path):
- ext = os.path.splitext(clean_path)[1] or '.png'
- filename = f"{uuid.uuid4()}{ext}"
- filepath = os.path.join(UPLOADS_DIR, filename)
- shutil.copy2(clean_path, filepath)
-
- rel_path = f"uploads/{filename}"
- final_url = url_for('static', filename=rel_path)
- return jsonify({'path': final_url, 'local_path': filepath})
- else:
- return jsonify({'error': 'File path not found on server'}), 404
-
- except Exception as e:
- print(f"Error downloading image: {e}")
- return jsonify({'error': str(e)}), 500
-
-def pinggy_thread(port,pinggy):
-
- server = {
- "Auto": "",
- "USA": "us.",
- "Europe": "eu.",
- "Asia": "ap.",
- "South America": "br.",
- "Australia": "au."
-
- }
-
- sv = server[Sever_Pinggy]
-
- import socket
- while True:
- time.sleep(0.5)
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- result = sock.connect_ex(('127.0.0.1', port))
- if result == 0:
- break
- sock.close()
- try:
- if pinggy != None:
- if ":" in pinggy:
- pinggy, ac, ps = pinggy.split(":")
- cmd = ["ssh", "-p", "443", f"-R0:localhost:{port}", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"{pinggy}@{sv}pro.pinggy.io", f'\"b:{ac}:{ps}\"']
- else:
- cmd = ["ssh", "-p", "443", f"-R0:localhost:{port}", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"{pinggy}@{sv}pro.pinggy.io"]
- else:
- cmd = ["ssh", "-p", "443", "-L4300:localhost:4300", "-o", "StrictHostKeyChecking=no", "-o", "ServerAliveInterval=30", f"-R0:localhost:{port}", "free.pinggy.io"]
- process = subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
- for line in iter(process.stdout.readline, ''):
- match = re.search(r'(https?://[^\s]+)', line)
- if match:
- url = match.group(1)
- # Bỏ qua các link dashboard
- if "dashboard.pinggy.io" in url:
- continue
- print(f"\033[92m🔗 Link online để sử dụng:\033[0m {url}")
- if pinggy == None:
- html="Link pinggy free hoạt động trong 60phút, khởi động lại hoặc đăng ký tại [dashboard.pinggy.io] để lấy token, nhập custom pinggy trong tệp Domain_sever.txt trên drive theo cú pháp 'pinggy-{token}'
"
- display(HTML(html))
- break
- except Exception as e:
- print(f"❌ Lỗi: {e}")
-
-def sever_flare(port, pinggy = None):
- threading.Thread(target=pinggy_thread, daemon=True, args=(port,pinggy,)).start()
-
-
-port_sever = 8888
-Sever_Pinggy = "Auto"
+ subprocess.run(['open', url])
+ except:
+ pass
if __name__ == '__main__':
- # Use ANSI green text so the startup banner stands out in terminals
- print("\033[32m" + "aPix Image Workspace running at:" + "\033[0m", flush=True)
- print("\033[32m" + f"http://localhost:{port_sever}" + " " + "\033[0m", flush=True)
- print("\033[32m" + f"http://127.0.0.1:{port_sever}" + "\033[0m", flush=True)
-
+ port_sever = 8888
+ # browser_thread = threading.Thread(target=open_browser, args=(f"http://127.0.0.1:{port_sever}",))
+ # browser_thread.start()
+
print("----------------------------------------------------------------")
print(" aPix v2.1 - STARTED")
print("----------------------------------------------------------------")
- initialize_config_files()
- app.run(debug=True, host='0.0.0.0', port=port_sever)
+ # Listen on all interfaces
+ app.run(host='0.0.0.0', port=port_sever, debug=True)
diff --git a/docker-compose.nas.yml b/docker-compose.nas.yml
deleted file mode 100644
index 884afa8..0000000
--- a/docker-compose.nas.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-version: '3.8'
-
-services:
- app:
- image: git.khoavo.myds.me/vndangkhoa/apix:v2
- container_name: apix_container
- ports:
- - "8558:8888"
- volumes:
- - ./static/generated:/app/static/generated
- - ./static/uploads:/app/static/uploads
- - ./config:/app/config
- environment:
- - CONFIG_DIR=/app/config
- - GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- - WHISK_COOKIES=${WHISK_COOKIES:-}
- restart: unless-stopped
- pull_policy: always
diff --git a/docker-compose.yml b/docker-compose.yml
index 2ef166c..4708bbc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,17 +1,14 @@
version: '3.8'
-
services:
app:
- build: .
- platform: linux/amd64
+ image: git.khoavo.myds.me/vndangkhoa/apix:v2.3
+ container_name: sdvn-apix-python
+ restart: unless-stopped
ports:
- "8558:8888"
- volumes:
- - ./static:/app/static
- - ./prompts.json:/app/prompts.json
- - ./user_prompts.json:/app/user_prompts.json
- - ./gallery_favorites.json:/app/gallery_favorites.json
environment:
- - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} # Optional for Whisk
- - WHISK_COOKIES=${WHISK_COOKIES:-}
- restart: unless-stopped
+ - PYTHONUNBUFFERED=1
+ volumes:
+ - ./config:/app/config
+ - ./data/generated:/app/static/generated
+ - ./data/uploads:/app/static/uploads
diff --git a/preview.jpeg b/preview.jpeg
deleted file mode 100644
index 53bb854..0000000
Binary files a/preview.jpeg and /dev/null differ
diff --git a/prompts.json b/prompts.json
index d8a056b..3566b88 100644
--- a/prompts.json
+++ b/prompts.json
@@ -1,4 +1,13 @@
[
+ {
+ "title": "Live Action Studio",
+ "preview": "https://images.unsplash.com/photo-1598550476439-c923097980d6?ixlib=rb-4.0.3&auto=format&fit=crop&w=500&q=60",
+ "prompt": "Tạo ảnh live action người thật nhân vật trong hình, sau đó làm cho nhân vật đang như đứng ở phim trường quay live action, nhân vật đang tạo dáng trong khi các nhân viên xung quanh đang chỉnh trang phục, ảnh toàn thân nhân vật, ảnh chụp bằng máy ảnh chất lượng cao, focus vào nhân vật chính, tiền cảnh mờ có camera và các thiết bị như đang chụp nén",
+ "author": "System Default",
+ "link": "",
+ "mode": "generate",
+ "category": "Cinematic"
+ },
{
"title": "Giải bài toán bằng ảnh chụp",
"preview": "https://linux.do/uploads/default/optimized/4X/1/5/1/1518d978c948fb70ab03c11537db1e1f5136249e_2_1000x1000.jpeg",
diff --git a/push_registry.sh b/push_registry.sh
index 994effe..4e75671 100755
--- a/push_registry.sh
+++ b/push_registry.sh
@@ -3,7 +3,7 @@
# Configuration
REGISTRY="git.khoavo.myds.me"
IMAGE_NAME="vndangkhoa/apix"
-TAG="v4"
+TAG="v2.3"
FULL_IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
echo "=== Building Docker Image for Linux/AMD64 ==="
diff --git a/run_app.bat b/run_app.bat
deleted file mode 100644
index 64ff3fe..0000000
--- a/run_app.bat
+++ /dev/null
@@ -1,65 +0,0 @@
-@echo off
-setlocal
-
-cd /d "%~dp0"
-
-if defined PYTHON_BIN goto :found_python
-
-for /f "delims=" %%P in ('where python3 2^>nul') do (
- set "PYTHON_BIN=%%~P"
- goto :found_python
-)
-for /f "delims=" %%P in ('where python 2^>nul') do (
- set "PYTHON_BIN=%%~P"
- goto :found_python
-)
-for /f "delims=" %%P in ('py -3 -c "import sys; print(sys.executable)" 2^>nul') do (
- set "PYTHON_BIN=%%~P"
- goto :found_python
-)
-
-echo Error: Python not found.
-echo Please install Python from https://www.python.org/downloads/
-echo or install it via the Microsoft Store.
-echo IMPORTANT: When installing, make sure to check "Add Python to PATH".
-pause
-exit /b 1
-
-:found_python
-if not exist ".venv" (
- echo Creating virtual environment...
- "%PYTHON_BIN%" -m venv .venv
- if errorlevel 1 (
- echo Error: Failed to create virtual environment.
- pause
- exit /b 1
- )
-)
-
-echo Activating virtual environment...
-call .venv\Scripts\activate.bat
-if errorlevel 1 (
- echo Error: Failed to activate virtual environment.
- pause
- exit /b 1
-)
-
-echo Installing dependencies...
-pip install -r requirements.txt
-if errorlevel 1 (
- echo Error: Failed to install dependencies.
- pause
- exit /b 1
-)
-
-echo Starting application...
-call .venv\Scripts\python.exe app.py
-if errorlevel 1 (
- echo Error: Application crashed or exited with an error.
- pause
- exit /b 1
-)
-
-echo Application finished successfully.
-pause
-endlocal
diff --git a/run_app.command b/run_app.command
deleted file mode 100755
index 8b7f012..0000000
--- a/run_app.command
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/bin/zsh
-cd "$(dirname "$0")"
-# Prefer python3 but fall back to python; allow overriding via env
-PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || command -v python)}"
-if [[ -z "$PYTHON_BIN" ]]; then
- echo "Error: Python not found."
- echo "Please install Python 3."
- echo " - On macOS: brew install python3"
- echo " - Or download from https://www.python.org/downloads/"
- read -k 1 "key?Press any key to exit..."
- exit 1
-fi
-
-# Create a virtual environment if missing, then activate it
-# Create a virtual environment if missing, then activate it
-if [[ ! -d ".venv" ]]; then
- echo "Creating virtual environment..."
- "$PYTHON_BIN" -m venv .venv || { echo "Error: Failed to create virtual environment."; read -k 1 "key?Press any key to exit..."; exit 1; }
-fi
-
-echo "Activating virtual environment..."
-source .venv/bin/activate || { echo "Error: Failed to activate virtual environment."; read -k 1 "key?Press any key to exit..."; exit 1; }
-
-# Ensure dependencies are available (skip reinstall if up-to-date)
-echo "Installing dependencies..."
-pip install -r requirements.txt || { echo "Error: Failed to install dependencies."; read -k 1 "key?Press any key to exit..."; exit 1; }
-
-# Start the Flask app on port 8888
-echo "Starting application..."
-.venv/bin/python app.py
-
-if [[ $? -ne 0 ]]; then
- echo "Error: Application crashed or exited with an error."
- read -k 1 "key?Press any key to exit..."
- exit 1
-fi
-
-echo "Application finished successfully."
-read -k 1 "key?Press any key to exit..."
diff --git a/run_app.sh b/run_app.sh
deleted file mode 100755
index 2c607ed..0000000
--- a/run_app.sh
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-
-cd "$(dirname "$0")"
-
-# Prefer python3 but fall back to python; allow override via environment
-PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || command -v python)}"
-if [[ -z "$PYTHON_BIN" ]]; then
- echo "Error: Python not found."
- echo "Please install Python 3."
- echo " - On macOS: brew install python3"
- echo " - On Linux: sudo apt install python3 (or equivalent for your distro)"
- echo " - Or download from https://www.python.org/downloads/"
- exit 1
-fi
-
-# Create a virtual environment if missing, then activate it
-# Create a virtual environment if missing, then activate it
-if [[ ! -d ".venv" ]]; then
- echo "Creating virtual environment..."
- "$PYTHON_BIN" -m venv .venv || { echo "Error: Failed to create virtual environment."; exit 1; }
-fi
-
-echo "Activating virtual environment..."
-source .venv/bin/activate || { echo "Error: Failed to activate virtual environment."; exit 1; }
-
-# Ensure dependencies are available
-echo "Installing dependencies..."
-pip install -r requirements.txt || { echo "Error: Failed to install dependencies."; exit 1; }
-
-# Start the Flask app on port 8888
-echo "Starting application..."
-exec .venv/bin/python app.py || { echo "Error: Application exited with an error."; exit 1; }
diff --git a/static/script.js b/static/script.js
index df1dfb4..f93761e 100644
--- a/static/script.js
+++ b/static/script.js
@@ -237,6 +237,12 @@ document.addEventListener('DOMContentLoaded', () => {
formData.append('reference_image_paths', JSON.stringify(referencePaths));
}
+ // Add Image Count for Whisk
+ const imageCountInput = document.getElementById('image-count');
+ if (imageCountInput && apiModelSelect && apiModelSelect.value === 'whisk') {
+ formData.append('image_count', imageCountInput.value);
+ }
+
return formData;
}
@@ -682,7 +688,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (data.image) {
- displayImage(data.image, data.image_data);
+ // Check if multiple images
+ if (data.images && data.images.length > 1) {
+ displayImage(data.images[0], data.image_datas ? data.image_datas[0] : null, data.images, data.image_datas);
+ } else {
+ displayImage(data.image, data.image_data);
+ }
gallery.load();
} else if (data.queue && data.prompts && Array.isArray(data.prompts)) {
// Backend returned more items - add them to queue
@@ -1840,7 +1851,7 @@ document.addEventListener('DOMContentLoaded', () => {
setViewState('error');
}
- function displayImage(imageUrl, imageData) {
+ function displayImage(imageUrl, imageData, allUrls = [], allDatas = []) {
let cacheBustedUrl = imageUrl;
if (!imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) {
cacheBustedUrl = withCacheBuster(imageUrl);
@@ -1863,6 +1874,9 @@ document.addEventListener('DOMContentLoaded', () => {
hasGeneratedImage = true; // Mark that we have an image
setViewState('result');
+ // Render variations if available
+ renderVariations(allUrls, allDatas);
+
// Persist image URL
try {
localStorage.setItem('gemini-app-last-image', imageUrl);
@@ -1871,6 +1885,85 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
+ // Create container for variations
+ const variationsContainer = document.createElement('div');
+ variationsContainer.className = 'variations-container';
+ variationsContainer.style.cssText = `
+ display: flex;
+ gap: 8px;
+ padding: 10px;
+ justify-content: center;
+ position: absolute;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 100;
+ background: rgba(0,0,0,0.5);
+ border-radius: 12px;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+ `;
+ // Insert into result state
+ const resultStateEl = document.getElementById('result-state');
+ if (resultStateEl) {
+ resultStateEl.appendChild(variationsContainer);
+ // Show on hover
+ resultStateEl.addEventListener('mouseenter', () => variationsContainer.style.opacity = '1');
+ resultStateEl.addEventListener('mouseleave', () => variationsContainer.style.opacity = '0');
+ variationsContainer.addEventListener('mouseenter', () => variationsContainer.style.opacity = '1'); // Keep visible when hovering container
+ variationsContainer.style.pointerEvents = 'auto';
+ }
+
+ function renderVariations(urls, datas) {
+ variationsContainer.innerHTML = '';
+
+ if (!urls || urls.length <= 1) {
+ variationsContainer.style.display = 'none';
+ return;
+ }
+
+ variationsContainer.style.display = 'flex';
+ variationsContainer.style.opacity = '1'; // Auto show when new batch arrives
+
+ urls.forEach((url, index) => {
+ const thumb = document.createElement('img');
+ thumb.src = datas && datas[index] ? `data:image/png;base64,${datas[index]}` : withCacheBuster(url);
+ thumb.className = 'variation-thumb';
+ thumb.style.cssText = `
+ width: 60px;
+ height: 60px;
+ object-fit: cover;
+ border-radius: 6px;
+ cursor: pointer;
+ border: 2px solid transparent;
+ transition: transform 0.2s, border-color 0.2s;
+ `;
+
+ thumb.addEventListener('click', (e) => {
+ e.stopPropagation();
+ displayImage(url, datas ? datas[index] : null, urls, datas);
+ // Highlight active
+ variationsContainer.querySelectorAll('img').forEach(img => img.style.borderColor = 'transparent');
+ thumb.style.borderColor = 'var(--accent-color)';
+ });
+
+ // Highlight current
+ if (generatedImage.src.includes(datas[index]) || generatedImage.src.includes(url.split('?')[0])) {
+ thumb.style.borderColor = 'var(--accent-color)';
+ }
+
+ variationsContainer.appendChild(thumb);
+ });
+
+ // Auto hide after 5 seconds if not interacting
+ setTimeout(() => {
+ if (!resultStateEl.matches(':hover')) {
+ variationsContainer.style.opacity = '0';
+ }
+ }, 5000);
+ }
+
async function handleCanvasDropUrl(imageUrl) {
const cleanedUrl = imageUrl;
displayImage(cleanedUrl);
diff --git a/templates/index.html b/templates/index.html
index f403c44..563eeb8 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -19,7 +19,7 @@
@@ -497,9 +511,20 @@
Whisk Cookies (dành cho ImageFX)
-
- F12 trên labs.google > Network > Request bất kỳ > Copy Request Headers > Cookie.
-
+
+
+ Hướng dẫn lấy Cookie (Mobile & PC)
+
+
+ PC: F12 > Network > F5 > Click request bất kỳ (vd: generate) > Copy
+ Header "Cookie".
+ Android: Dùng Kiwi Browser > Cài đặt > Developer Tools >
+ Network.
+ iOS: Dùng ứng dụng "Web Inspector" hoặc kết nối với Mac (Safari
+ Developer Mode).
+
+
Model
diff --git a/whisk_client.py b/whisk_client.py
index f8cf6d5..f804a91 100644
--- a/whisk_client.py
+++ b/whisk_client.py
@@ -130,7 +130,7 @@ def upload_reference_image(image_path, cookies):
logger.error(f"Error uploading image: {e}")
raise e
-def generate_image_whisk(prompt, cookie_str, **kwargs):
+def generate_image_whisk(prompt, cookie_str, image_count=4, **kwargs):
cookies = parse_cookies(cookie_str)
if not cookies:
raise WhiskClientError("No valid cookies found")
@@ -144,9 +144,6 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
media_generation_id = upload_reference_image(reference_image_path, cookies)
except Exception as e:
logger.error(f"Failed to upload reference image: {e}")
- # Fallback to Text-to-Image? Or fail?
- # If user wants reference, we should probably fail or warn.
- # For now, let's log and continue (media_generation_id will be None -> T2I)
pass
aspect_ratio_map = {
@@ -169,6 +166,10 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
# BRANCH: Use Recipe Endpoint if Reference Image exists
if media_generation_id:
target_endpoint = RECIPE_ENDPOINT
+ # Recipe endpoint doesn't strictly support candidatesCount in the same way,
+ # but the backend often generates 4 by default for Recipe too?
+ # Actually standard ImageFX recipes produce 4.
+ # We will assume it produces multiple and we collect them.
payload = {
"clientContext": {
"workflowId": str(uuid.uuid4()),
@@ -190,27 +191,25 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
}
else:
# BRANCH: Use Generate Endpoint for Text-to-Image
- # NOTE: Payload for generateImage is inferred to be userInput based.
- # If this fails, we might need further inspection, but Recipe flow is the priority.
target_endpoint = GENERATE_ENDPOINT
payload = {
"userInput": {
- "candidatesCount": 2,
+ "candidatesCount": image_count,
"prompts": [prompt],
"seed": seed
},
"clientContext": {
"workflowId": str(uuid.uuid4()),
- "tool": "IMAGE_FX", # Usually ImageFX for T2I
+ "tool": "IMAGE_FX",
"sessionId": str(int(time.time() * 1000))
},
"modelInput": {
- "modelNameType": "IMAGEN_3_5", # Usually Imagen 3 for ImageFX
+ "modelNameType": "IMAGEN_3_5",
"aspectRatio": aspect_ratio_enum
}
}
- logger.info(f"Generating image. Endpoint: {target_endpoint}, Prompt: {prompt}")
+ logger.info(f"Generating image. Endpoint: {target_endpoint}, Prompt: {prompt}, Count: {image_count}")
try:
response = requests.post(
@@ -230,8 +229,6 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
except (json.JSONDecodeError, WhiskClientError) as e:
if isinstance(e, WhiskClientError): raise e
- # Additional T2I Fallback: If generateImage fails 400, try Recipe with empty media?
- # Not implementing strictly to avoid loops, but helpful mental note.
raise WhiskClientError(f"Generation failed ({response.status_code}): {error_text}")
# Parse Response
@@ -250,15 +247,14 @@ def generate_image_whisk(prompt, cookie_str, **kwargs):
import json
logger.error(f"WHISK DEBUG - Full Response: {json.dumps(json_resp)}")
debug_info = json.dumps(json_resp)
- # check for common non-standard errors
if 'error' in json_resp:
err_msg = json_resp['error']
raise WhiskClientError(f"Whisk Error: {err_msg} | Valid Cookies? Check logs.")
- # Return the full structure in the error so user can see it in UI
raise WhiskClientError(f"Whisk API returned NO IMAGES. Google says: {debug_info}")
- return base64.b64decode(images[0])
+ # Return LIST of bytes
+ return [base64.b64decode(img_str) for img_str in images]
except requests.exceptions.Timeout:
raise WhiskClientError("Timout connecting to Google Whisk.")